Na minha VPS correm várias aplicações em Docker: este site, uma app de registo de apostas, um pica-ponto. Cada uma tem o seu subdomínio, todas com HTTPS e nenhuma com portas expostas diretamente. O padrão é sempre o mesmo e cabe numa página.

A ideia

  • Cada container publica a sua porta só em 127.0.0.1 (por exemplo 127.0.0.1:5180).
  • O nginx no host é o único a ouvir nas portas 80 e 443 e encaminha cada subdomínio para a porta certa.
  • Um só certificado wildcard (*.dominio) cobre todos os subdomínios, e renova-se sozinho.

1. DNS na Cloudflare

Cria um registo A para o domínio e outro para * (ou um por subdomínio), a apontar para o IP da VPS. Podes deixá-los em "DNS only" ou com o proxy laranja ligado; o certificado funciona nos dois casos, porque a validação é feita por DNS e não por HTTP.

2. Certificado wildcard por DNS-01

Na Cloudflare, cria um API token com a permissão Zone → DNS → Edit, limitado ao teu domínio. Depois, no servidor:

sudo apt install certbot python3-certbot-dns-cloudflare
sudo mkdir -p /root/.secrets
echo "dns_cloudflare_api_token = O_TEU_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 exemplo.dev -d '*.exemplo.dev' \
  --deploy-hook "systemctl reload nginx"

O certbot instala um timer do systemd que renova o certificado antes de expirar. O --deploy-hook recarrega o nginx sempre que há renovação, para não ficares com um certificado novo no disco e o antigo em memória.

3. Um bloco do nginx por aplicação

Primeiro, no bloco http (por exemplo em /etc/nginx/conf.d/upgrade.conf), um mapa para WebSockets:

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

Depois, um ficheiro por aplicação em /etc/nginx/sites-available/:

server {
    listen 80;
    server_name app.exemplo.dev;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name app.exemplo.dev;

    ssl_certificate     /etc/letsencrypt/live/exemplo.dev/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/exemplo.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. Se ligares o proxy da Cloudflare

  • Põe o modo SSL/TLS em "Full (strict)". Em "Flexible" a Cloudflare fala com o teu servidor em HTTP e crias um ciclo de redirecionamentos.
  • Os pedidos passam a chegar com o IP da Cloudflare. Para veres o IP real nos logs (e para o Wazuh e o fail2ban funcionarem bem), usa real_ip_header CF-Connecting-IP com set_real_ip_from para os intervalos publicados em cloudflare.com/ips.

Porque é que este padrão funciona

Adicionar uma aplicação nova é copiar um ficheiro, mudar o server_name e a porta, e recarregar o nginx. Os containers nunca ficam expostos diretamente, há um só sítio onde gerir TLS e cabeçalhos de segurança, e os certificados tratam de si próprios.

Resumo: portas dos containers só em 127.0.0.1, um certificado wildcard por DNS-01 com deploy hook, e um server block por subdomínio.