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.