VPS

VPS Setup Guide (End-to-End)

Author: Ahmad Hafizh Muttaqi
Style: Senior Software Engineer Playbook
Scope: From clean VPS → production-ready setup with Ghost, RabbitMQ, Directus, Docker, Nginx, SSL, and shared databases.

0. High-Level Architecture

Internet
   │
   ▼
Nginx (80 / 443, SSL by Certbot)
   ├── blog.ahmadswork.space   → Ghost (Node 18)
   ├── cms-admin.ahmadswork.space → Directus (Docker)
   └── mq.ahmadswork.space     → RabbitMQ Management UI

Docker Network (devnet)
   ├── PostgreSQL 16
   ├── Redis 7
   ├── RabbitMQ
   └── Directus

Key principles:

  • One VPS, multiple services
  • Nginx as a single entry point
  • Docker for infra services
  • Node version isolation (Node 18 only where required)
  • Single Postgres instance reused

1. Base VPS Preparation

1.1 Update system

sudo apt update && sudo apt upgrade -y

1.2 Install core tools

sudo apt install -y \
  curl ca-certificates gnupg lsb-release \
  nginx git ufw

1.3 Firewall

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

2. Docker & Docker Compose

2.1 Install Docker

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker

2.2 Install Docker Compose plugin

sudo apt install -y docker-compose-plugin

Verify:

docker compose version

3. Shared Infrastructure Stack (Postgres + Redis)

This stack is reused by Directus and application services.

3.1 docker-compose.yml

services:
  postgres:
    image: postgres:16
    container_name: postgres16
    restart: unless-stopped
    environment:
      POSTGRES_USER: ahmad
      POSTGRES_PASSWORD: supersecret_pg
      POSTGRES_DB: appdb
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - devnet

  redis:
    image: redis:7
    container_name: redis7
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    ports:
      - "127.0.0.1:6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - devnet

volumes:
  postgres_data:
  redis_data:

networks:
  devnet:

Start services:

docker compose up -d

4. Ghost Blog Setup (Node 18 Only)

4.1 Node isolation strategy

  • System Node version untouched
  • Node 18 scoped only to Ghost directory
  • Avoid global conflicts

4.2 Install Node 18 locally

cd /var/www/blog
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs

4.3 Install Ghost CLI

sudo npm install -g ghost-cli

4.4 Install Ghost

sudo mkdir -p /var/www/blog
sudo chown $USER:$USER /var/www/blog
cd /var/www/blog

ghost install

Key choices:

  • Database: MySQL 8
  • URL: https://blog.ahmadswork.space
  • Nginx: No (manual)
  • SSL: Already handled by certbot

5. Nginx for Ghost

5.1 blog.ahmadswork.space

server {
    listen 80;
    server_name blog.ahmadswork.space;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name blog.ahmadswork.space;

    ssl_certificate /etc/letsencrypt/live/blog.ahmadswork.space/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/blog.ahmadswork.space/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:2368;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

6. RabbitMQ (Docker + Public URL)

6.1 Why RabbitMQ

  • Reliable background job processing
  • Durable queues
  • Horizontal scale ready

6.2 docker-compose.yml

services:
  rabbitmq:
    image: rabbitmq:3-management
    container_name: rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: ahmad
      RABBITMQ_DEFAULT_PASS: supersecret_rabbit
    ports:
      - "127.0.0.1:5672:5672"
      - "127.0.0.1:15672:15672"
    networks:
      - devnet

6.3 Connection URL

amqp://ahmad:supersecret_rabbit@mq.ahmadswork.space:5672

This is acceptable for a playground server.


7. RabbitMQ Queue Design (Important Concept)

x-queue-type: quorum

Why quorum queues?

  • Data safety (replication)
  • Crash-safe
  • Recommended for modern RabbitMQ

Alternatives:

TypeProsCons
classicFastData loss risk
quorumSafeSlightly heavier

Decision: Use quorum for critical jobs.


8. Directus (Docker, Reuse Existing Postgres)

8.1 Key Decision

  • ❌ Do NOT create a new Postgres
  • ✅ Reuse existing postgres16

8.2 Docker Networking Rule (Critical)

Containers cannot use localhost to reach other containers.

Correct host:

postgres

8.3 Directus docker-compose.yml

services:
  directus:
    image: directus/directus:latest
    container_name: directus
    restart: unless-stopped
    ports:
      - "127.0.0.1:8055:8055"
    environment:
      KEY: "supersecretkey"
      SECRET: "supersecretsecret"

      DB_CLIENT: "pg"
      DB_HOST: "postgres"
      DB_PORT: 5432
      DB_DATABASE: "appdb"
      DB_USER: "ahmad"
      DB_PASSWORD: "supersecret_pg"

      ADMIN_EMAIL: "admin@ahmadswork.space"
      ADMIN_PASSWORD: "SuperStrongPassword"

    networks:
      - devnet

9. Nginx for Directus

cms-admin.ahmadswork.space

server {
    listen 80;
    server_name cms-admin.ahmadswork.space;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name cms-admin.ahmadswork.space;

    ssl_certificate /etc/letsencrypt/live/cms-admin.ahmadswork.space/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cms-admin.ahmadswork.space/privkey.pem;

    client_max_body_size 100M;

    location / {
        proxy_pass http://127.0.0.1:8055;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

10. Certbot & DNS Lessons Learned

10.1 Wildcard DNS

  • *.ahmadswork.space does NOT guarantee cert issuance
  • Certbot still requires DNS resolution

10.2 Correct flow

  1. DNS resolves subdomain
  2. Nginx responds on port 80
  3. Certbot validates
  4. SSL issued

11. Common Errors & Fixes

❌ ENOTFOUND host.docker.internal

Reason:

  • Linux does NOT support this by default

Fix:

  • Use Docker service name (postgres)

❌ Knex Timeout / Pool Full

Cause:

  • Directus cannot reach Postgres

Fix:

  • Same Docker network
  • Correct DB_HOST

12. Final Checklist

  • Nginx reverse proxy
  • SSL for all subdomains
  • Ghost (Node 18 isolated)
  • RabbitMQ (Docker)
  • Directus (Docker)
  • Shared Postgres & Redis
  • Proper Docker networking