My VPS runs several apps in Docker: this site, a bet-tracking app, a time clock. Each has its own subdomain, all use HTTPS, and none has ports exposed directly. The pattern is always the same and fits on one page.
The idea
- Each container publishes its port on 127.0.0.1 only (for example 127.0.0.1:5180).
- nginx on the host is the only thing listening on ports 80 and 443, and it routes each subdomain to the right port.
- A single wildcard certificate (*.domain) covers every subdomain and renews itself.
1. DNS on Cloudflare
Create an A record for the domain and one for * (or one per subdomain), pointing at the VPS IP. You can leave them as "DNS only" or turn the orange proxy on; the certificate works either way, because validation happens over DNS, not HTTP.
2. Wildcard certificate via DNS-01
In Cloudflare, create an API token with the Zone → DNS → Edit permission, limited to your domain. Then, on the server:
sudo apt install certbot python3-certbot-dns-cloudflare
sudo mkdir -p /root/.secrets
echo "dns_cloudflare_api_token = YOUR_TOKEN" | sudo tee /root/.secrets/cloudflare.ini
sudo chmod 600 /root/.secrets/cloudflare.ini
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d example.dev -d '*.example.dev' \
--deploy-hook "systemctl reload nginx"certbot installs a systemd timer that renews the certificate before it expires. The --deploy-hook reloads nginx on every renewal, so you don't end up with a new certificate on disk and the old one in memory.
3. One nginx block per app
First, in the http context (for example /etc/nginx/conf.d/upgrade.conf), a map for WebSockets:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Then one file per app in /etc/nginx/sites-available/:
server {
listen 80;
server_name app.example.dev;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name app.example.dev;
ssl_certificate /etc/letsencrypt/live/example.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.dev/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-Frame-Options SAMEORIGIN always;
location / {
proxy_pass http://127.0.0.1:5180;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx4. If you turn on the Cloudflare proxy
- Set SSL/TLS mode to "Full (strict)". On "Flexible", Cloudflare talks to your server over HTTP and you get a redirect loop.
- Requests now arrive from Cloudflare's IPs. To see the real client IP in your logs (and keep Wazuh and fail2ban useful), use real_ip_header CF-Connecting-IP with set_real_ip_from for the ranges published at cloudflare.com/ips.
Why this pattern works
Adding a new app means copying one file, changing server_name and the port, and reloading nginx. Containers are never exposed directly, there's one place to manage TLS and security headers, and certificates look after themselves.
In short: container ports on 127.0.0.1 only, one wildcard certificate via DNS-01 with a deploy hook, and one server block per subdomain.