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 nginx

4. 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.