One VPS can run many containerized apps — a blog, a dashboard, an API — each on its own domain with HTTPS. The pattern is simple: containers listen on private ports, and Nginx on the host routes each domain to the right container and handles TLS.

            ┌──────────── VPS ────────────┐
Internet ──▶│ Nginx :80/:443               │
            │   app1.example.com ─▶ 127.0.0.1:3001 (container)
            │   app2.example.com ─▶ 127.0.0.1:3002 (container)
            └──────────────────────────────┘

1. Run containers on localhost only

Publishing a port as -p 3001:3000 exposes it to the internet — and Docker’s iptables rules bypass UFW. Bind to 127.0.0.1 instead:

# compose.yaml
services:
  app1:
    image: ghcr.io/example/app1:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:3001:3000"
  app2:
    image: ghcr.io/example/app2:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:3002:8080"
docker compose up -d
curl -I http://127.0.0.1:3001

2. Point DNS at the VPS

Create A records for app1.example.com and app2.example.com pointing to your server’s IP.

3. Install Nginx and Certbot

apt install -y nginx certbot python3-certbot-nginx
ufw allow 'Nginx Full'

4. One server block per app

/etc/nginx/sites-available/app1.example.com:

server {
    listen 80;
    server_name app1.example.com;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;

        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;

        # WebSocket support
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_read_timeout 300s;
        client_max_body_size 50m;
    }
}

Add this once in the http {} block (e.g. /etc/nginx/conf.d/websocket.conf):

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Repeat for app2 with port 3002, then enable and test:

ln -s /etc/nginx/sites-available/app1.example.com /etc/nginx/sites-enabled/
ln -s /etc/nginx/sites-available/app2.example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

5. Add HTTPS

certbot --nginx -d app1.example.com -d app2.example.com --redirect

Certbot adds the certificate configuration and an HTTP→HTTPS redirect, and renews automatically.

6. Tell the app it’s behind a proxy

Many apps need to know their public URL and trust the proxy headers, otherwise they generate http:// links or log the proxy’s IP. Typical settings: BASE_URL=https://app1.example.com, “trust proxy” (Express), TRUSTED_PROXIES (Laravel), FORWARDED_ALLOW_IPS (Gunicorn/Uvicorn).

Alternative: a containerized proxy

If you’d rather keep everything in Docker Compose, Caddy or Traefik can run as containers on a shared Docker network and obtain certificates automatically. A Caddy example:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["80:80", "443:443", "443:443/udp"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
  app1:
    image: ghcr.io/example/app1:latest
  app2:
    image: ghcr.io/example/app2:latest
volumes:
  caddy_data:
# Caddyfile
app1.example.com {
    reverse_proxy app1:3000
}
app2.example.com {
    reverse_proxy app2:8080
}

Here the apps don’t publish any ports at all — only Caddy is reachable.

Troubleshooting

Symptom Check
502 Bad Gateway Is the container running? curl http://127.0.0.1:3001; correct port mapping?
Redirect loops App forces HTTPS but doesn’t see X-Forwarded-Proto; enable proxy trust
WebSockets disconnect Upgrade/Connection headers, proxy_read_timeout
Uploads fail client_max_body_size

Summary

Bind containers to localhost, let Nginx (or Caddy/Traefik) route domains and terminate TLS, and pass the right headers. Our Docker VPS page covers recommended sizes for running several containers. More: reverse proxy on Linux and secure WebSocket server.

  • Docker
  • Nginx
  • Reverse Proxy