DEV Community

Cristian Tala
Cristian Tala

Posted on

How I Run 8 Services on 1 VPS with Auto SSL (And Why Caddy > nginx)

I run 8 services in production on a single $12/month VPS:

  • n8n (automation)
  • Listmonk (newsletters)
  • NocoDB (task management)
  • PostgreSQL (database)
  • Excalidraw (diagrams)
  • OpenClaw (AI agent)
  • Markdown viewer
  • Assets CDN

Uptime: 99.8% (6 months).

SSL: Automatic wildcard cert (Let's Encrypt).

Deployment time: 5 minutes per new service.

Equivalent SaaS costs: $59/month → Saving $552/year.

It's not magic. It's Docker + Caddy (the reverse proxy you should be using instead of nginx).

I'll show you the complete setup (with configs ready to copy).

The Problem: Hosting Multiple Services Is Tedious

Traditional setup (nginx + manual SSL):

  • Install service on host (dependency hell)
  • Configure nginx location block (regex hell)
  • Setup certbot for SSL
  • Create cron for renewal
  • Pray nothing breaks

Per service: 30-60 minutes.

SSL renewal: Randomly fails every 3 months (cert expired → downtime).

Rollback: Impossible (you installed directly on the host).

The Solution: Docker + Caddy

Docker: Containers = isolation + portability.

Caddy: Reverse proxy with automatic SSL (zero config).

Setup time: 5 minutes per service.

SSL: Automatic (Caddy requests + renews certs).

Rollback: docker run old_image.

Architecture (High-Level)

Internet
  ↓
Cloudflare DNS (*.yourdomain.com → VPS IP)
  ↓
Caddy (reverse proxy, port 443)
  ↓
  ├─ n8n.yourdomain.com → Docker container n8n:5678
  ├─ listmonk.yourdomain.com → Docker container listmonk:9000
  ├─ nocodb.yourdomain.com → Docker container nocodb:8080
  └─ assets.yourdomain.com → Static files /var/www/assets/

PostgreSQL (Docker internal network)
  ↑
  ├─ Listmonk connects via Docker network
  └─ NocoDB connects via Docker network
Enter fullscreen mode Exit fullscreen mode

Key insight: Services do NOT expose ports to the internet. Only Caddy (443) is exposed.

Docker Compose (Main Services)

File: /home/user/docker/docker-compose.yml

version: '3.8'

services:
  # PostgreSQL (shared database)
  postgres:
    image: postgres:15
    container_name: postgres
    restart: always
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: listmonk
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - internal

  # Listmonk (newsletter platform)
  listmonk:
    image: listmonk/listmonk:latest
    container_name: listmonk
    restart: always
    ports:
      - "9000:9000"
    depends_on:
      - postgres
    environment:
      LISTMONK_app__address: "0.0.0.0:9000"
      LISTMONK_db__host: postgres
      LISTMONK_db__port: 5432
      LISTMONK_db__user: ${POSTGRES_USER}
      LISTMONK_db__password: ${POSTGRES_PASSWORD}
      LISTMONK_db__database: listmonk
    networks:
      - internal

  # n8n (workflow automation)
  n8n-dev:
    image: n8nio/n8n:latest
    container_name: n8n-dev
    restart: always
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=n8n.yourdomain.com
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.yourdomain.com/
    volumes:
      - n8n_data:/home/node/.n8n
    networks:
      - internal

  # NocoDB (Airtable alternative)
  nocodb:
    image: nocodb/nocodb:latest
    container_name: nocodb
    restart: always
    ports:
      - "8081:8080"
    depends_on:
      - postgres
    environment:
      NC_DB: "pg://postgres:5432?u=${POSTGRES_USER}&p=${POSTGRES_PASSWORD}&d=nocodb"
    volumes:
      - nocodb_data:/usr/app/data
    networks:
      - internal

  # Excalidraw (diagram tool)
  excalidraw:
    image: excalidraw/excalidraw:latest
    container_name: excalidraw
    restart: always
    ports:
      - "8080:80"
    networks:
      - internal

volumes:
  postgres_data:
  n8n_data:
  nocodb_data:

networks:
  internal:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode
# Start all services
cd /home/user/docker
docker-compose up -d

# View logs
docker-compose logs -f listmonk

# Stop
docker-compose down
Enter fullscreen mode Exit fullscreen mode

Caddy (Reverse Proxy + Automatic SSL)

File: /etc/caddy/Caddyfile

# Global options
{
    email your-email@example.com

    # Cloudflare DNS challenge for wildcard certs
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

# n8n (automation)
n8n.yourdomain.com {
    reverse_proxy localhost:5678

    header {
        Strict-Transport-Security "max-age=31536000;"
        X-Frame-Options "DENY"
        X-Content-Type-Options "nosniff"
    }
}

# Listmonk (newsletter)
listmonk.yourdomain.com {
    reverse_proxy localhost:9000
}

# NocoDB (task manager)
nocodb.yourdomain.com {
    reverse_proxy localhost:8081
}

# Excalidraw (diagrams)
draw.yourdomain.com {
    reverse_proxy localhost:8080
}

# Assets CDN (static files)
assets.yourdomain.com {
    root * /var/www/assets
    file_server browse

    # CORS for public assets
    header Access-Control-Allow-Origin "*"
}
Enter fullscreen mode Exit fullscreen mode
# Install Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

# Reload config (zero downtime)
sudo caddy reload --config /etc/caddy/Caddyfile
Enter fullscreen mode Exit fullscreen mode

Why Caddy > nginx

Configuration Comparison

nginx (30 lines to configure a single service with SSL):

server {
    listen 80;
    server_name n8n.yourdomain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name n8n.yourdomain.com;
    ssl_certificate /etc/letsencrypt/live/n8n.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/n8n.yourdomain.com/privkey.pem;
    # ... more SSL config ...
    location / {
        proxy_pass http://localhost:5678;
        proxy_set_header Host $host;
        # ... more headers ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Caddy (2 lines):

n8n.yourdomain.com {
    reverse_proxy localhost:5678
}
Enter fullscreen mode Exit fullscreen mode

SSL Renewal

nginx: Install certbot, run certbot --nginx, create cron, hope it doesn't break.

Caddy: Nothing. Automatic SSL renewal built-in.

Setup Time

nginx: Config (15 min) + Certbot (10 min) + SSL debugging (10 min) = 35 min

Caddy: Config (2 min) + SSL (automatic) = 2 min

Adding a New Service (5 Minutes)

Example: Adding Plausible Analytics

1. Add to Docker Compose

  plausible:
    image: plausible/analytics:latest
    container_name: plausible
    restart: always
    ports:
      - "8082:8000"
    environment:
      BASE_URL: https://analytics.yourdomain.com
      SECRET_KEY_BASE: ${PLAUSIBLE_SECRET}
    networks:
      - internal
Enter fullscreen mode Exit fullscreen mode

2. Add to Caddyfile

analytics.yourdomain.com {
    reverse_proxy localhost:8082
}
Enter fullscreen mode Exit fullscreen mode

3. Deploy

# Start container
docker-compose up -d plausible

# Reload Caddy (zero downtime)
caddy reload --config /etc/caddy/Caddyfile

# Verify
curl https://analytics.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

Total time: 5 minutes.

SSL: Already works (wildcard cert covers analytics.yourdomain.com).

Wildcard SSL (DNS Challenge)

Why wildcard:

  • 1 cert covers all *.yourdomain.com
  • Adding a new subdomain = 0 SSL work

Cloudflare API Setup:

  1. Cloudflare dashboard → API Tokens
  2. Create Token → Edit Zone DNS
  3. Permissions: Zone:DNS:Edit
  4. Zone: yourdomain.com
export CLOUDFLARE_API_TOKEN="your_token_here"
echo 'export CLOUDFLARE_API_TOKEN="your_token_here"' >> ~/.bashrc
Enter fullscreen mode Exit fullscreen mode

Caddy auto-requests the cert: Requests wildcard from Let's Encrypt → creates DNS TXT via Cloudflare API → Let's Encrypt verifies → issues cert. Total time: 30-60 seconds. Manual steps: 0.

Backups

PostgreSQL (daily):

#!/bin/bash
# backup-postgres.sh
docker exec postgres pg_dumpall -U listmonk | gzip > /backups/postgres-$(date +%Y-%m-%d).sql.gz

# Keep last 7 days only
find /backups -name "postgres-*.sql.gz" -mtime +7 -delete
Enter fullscreen mode Exit fullscreen mode

Cron:

0 2 * * * /home/user/backup-postgres.sh
Enter fullscreen mode Exit fullscreen mode

Results (6 Months in Production)

Uptime: 99.8% (only 1 reboot for kernel update).

Services running: 8.

SSL cert renewals: 12 (all automatic, 0 manual).

Deployment time: 5 min avg per service.

New services added: 5.

Time saved: 40 min per deployment (vs nginx) × 5 deployments = 3.3 hours

Costs

Item Cost
VPS (Hostinger) — 4 CPU, 8 GB RAM $12/month
Domain $1/month
Cloudflare $0
Let's Encrypt SSL $0
Docker + Caddy $0
Total $13/month

vs SaaS equivalent:

  • n8n Cloud: $20/month
  • Managed Listmonk: $29/month
  • NocoDB Cloud: $10/month
  • Total SaaS: $59/month

Annual savings: $552

Setup From Scratch (Checklist)

  • [ ] VPS (Ubuntu 22.04+) — Hostinger VPS works great for this
  • [ ] Domain (with DNS access)
  • [ ] Cloudflare account (for wildcard SSL)
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
Enter fullscreen mode Exit fullscreen mode

Then: create docker-compose.yml, configure Caddyfile, docker-compose up -d, caddy reload.

Conclusion

Setup time: 1 day (one-time).

Annual savings: $552.

ROI: $552 saved / 8 hours setup = $69/hour.

When to self-host:

  • You already pay for a VPS
  • You have 3+ services to run
  • You want full control

When NOT to:

  • You don't know Docker (and don't want to learn)
  • Total SaaS costs are <$50/month (not worth the effort)

If you're paying >$50/month on SaaS you could self-host, this setup pays for itself immediately.

Do you self-host? What's your stack? Share in the comments.

📝 Originally published in Spanish at cristiantala.com

Top comments (0)