Nginx como reverse proxy pra containers Docker: guia prático
Configure Nginx como reverse proxy roteando múltiplos containers Docker por domínio, com TLS, headers corretos e network isolada.
Quando você tem mais de um container HTTP rodando na mesma VPS, publicar portas no host vira um caos rapidinho: cada serviço numa porta diferente, firewall com 8 buracos, certificado TLS replicado, logs espalhados. A solução padrão da indústria é colocar um Nginx na frente fazendo reverse proxy — uma porta pública (443), múltiplos backends roteados por hostname.
Este tutorial mostra como rodar o Nginx em container, ligado aos backends via uma network Docker dedicada, sem expor nenhuma porta dos backends pro host. Vamos cobrir routing por domínio, headers de proxy corretos, TLS automático via Let’s Encrypt e reload sem downtime.
O alvo é um sysadmin com 2-3 containers já rodando (uma API, uma admin,
um app público) que quer servir tudo em api.exemplo.com,
admin.exemplo.com e exemplo.com a partir do mesmo IP. Tempo
estimado: 30-40 minutos pra config inicial, contando emissão de
certificado.
Pré-requisitos
VPS com Ubuntu 22.04+ ou Debian 12+, Docker Engine 24+ e Docker Compose
v2 instalados. Acesso root ou usuário no grupo docker. DNS dos
domínios já apontando pro IP da VPS (registros A com TTL baixo durante
setup). Portas 80 e 443 abertas no firewall e nenhum outro processo
escutando nelas.
80 443 proxy-net /opt/nginx-proxy Antes de continuar, valide que nada está usando as portas:
sudo ss -tlnp | grep -E ':80|:443'
Se aparecer Apache, outro Nginx do host ou algum container já bindado, pare antes de seguir — duas coisas escutando 443 não convivem.
Arquitetura da solução
O desenho é: uma network Docker user-defined chamada proxy-net onde
moram o Nginx e todos os backends. Os backends não publicam porta
nenhuma no host (sem -p no compose). O Nginx é o único container com
ports: 80:80 e 443:443. Dentro da network, ele resolve cada backend
pelo nome do container via DNS interno do Docker.
Vantagens: o atacante na internet pública só vê 80/443. Os backends ficam isolados — mesmo que a aplicação tenha um endpoint admin sem auth, ele não está exposto. E adicionar um novo serviço é uma alteração no compose mais um server block no Nginx, sem mexer em firewall.
Criando a network dedicada
Containers em networks diferentes não se enxergam por nome (default bridge é a exceção, mas DNS lá só funciona por IP, não por nome). Toda nossa stack precisa estar na mesma user-defined network.
Crie a network proxy-net:
docker network create proxy-netIsso aparece em docker network ls como driver bridge. Você pode
checar com docker network inspect proxy-net — vai estar vazia por
enquanto.
Crie o diretório de trabalho do reverse proxy:
sudo mkdir -p /opt/nginx-proxy/{conf.d,certs,vhost,html}
sudo chown -R $USER:$USER /opt/nginx-proxy
cd /opt/nginx-proxyconf.d guarda os server blocks por domínio. certs é onde os
certificados emitidos vão morar. vhost permite override por host.
html serve a ACME challenge do Let’s Encrypt.
Subindo o Nginx em container
Vamos usar a imagem oficial nginx:1.27-alpine com config montada via
bind mount. Assim você edita arquivos no host e recarrega o Nginx sem
rebuild de imagem.
Crie /opt/nginx-proxy/docker-compose.yml:
services:
nginx:
image: nginx:1.27-alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro
- ./vhost:/etc/nginx/vhost.d:ro
- ./html:/usr/share/nginx/html:ro
networks:
- proxy-net
networks:
proxy-net:
external: trueexternal: true diz pro Compose que a network já existe (criamos no
passo 01) — sem isso, ele cria uma nova prefixada com nome do projeto e
seu Nginx não enxerga os backends.
Crie um server block default em conf.d/default.conf:
server {
listen 80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
return 444;
}
}return 444 é o código não-padrão do Nginx que fecha a conexão sem
resposta — útil pra rejeitar requests com Host desconhecido (bots
scanning IP direto).
Suba o Nginx:
docker compose up -d
docker compose logs nginxOs logs devem mostrar start worker processes sem erro. Se aparecer
bind() to 0.0.0.0:80 failed, alguma coisa no host está na porta —
volte e investigue antes de continuar.
Conectando os backends ao proxy
Os containers que você já tem rodando provavelmente estão na network
default (bridge) ou numa criada pelo Compose deles. Eles precisam
entrar também na proxy-net pra que o Nginx os resolva por nome.
Adicione proxy-net ao compose de cada backend. Exemplo pra uma API:
services:
api:
image: minha-api:latest
container_name: api-backend
restart: unless-stopped
networks:
- default
- proxy-net
# NOTA: sem `ports:` — não publica no host
networks:
proxy-net:
external: trueRecrie o container: docker compose up -d --force-recreate. Mantém a
network default pra dependências internas (banco, redis) e adiciona
proxy-net pra ser alcançável pelo Nginx.
Valide que o Nginx enxerga o backend pelo nome:
docker exec nginx-proxy ping -c 2 api-backendResposta com IP 172.x.x.x significa DNS da network funcionando.
bad address significa que o backend não está em proxy-net — volte e
confira o docker network inspect proxy-net.
Configurando o reverse proxy por domínio
Cada domínio vira um arquivo separado em conf.d/. Isso facilita
versionamento e debug — você lê só o vhost relevante.
Crie conf.d/api.exemplo.com.conf:
upstream api_backend {
server api-backend:3000;
keepalive 32;
}
server {
listen 80;
server_name api.exemplo.com;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name api.exemplo.com;
ssl_certificate /etc/nginx/certs/api.exemplo.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/api.exemplo.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
location / {
proxy_pass http://api_backend;
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 Connection "";
proxy_read_timeout 60s;
}
}upstream nomeado com keepalive 32 mantém pool de conexões reusáveis
pro backend — reduz latência em 5-15ms por request em comparação a abrir
TCP novo toda vez. proxy_http_version 1.1 + Connection "" é
obrigatório pra keepalive funcionar.
Pra admin.exemplo.com e exemplo.com, copie o arquivo trocando
server_name, upstream e o path do certificado. Não tente colocar
múltiplos server_name no mesmo bloco — Let’s Encrypt emite por nome,
e isso complica renovação.
Emitindo certificados TLS
Vamos usar certbot standalone via container, evitando instalar
Python no host. O Nginx atende o challenge em /.well-known/acme-challenge/
que configuramos no passo 04.
Emita o certificado pra cada domínio:
docker run --rm \
-v /opt/nginx-proxy/certs:/etc/letsencrypt \
-v /opt/nginx-proxy/html:/var/www/html \
certbot/certbot certonly \
--webroot -w /var/www/html \
-d api.exemplo.com \
--email [email protected] \
--agree-tos --no-eff-emailOs arquivos fullchain.pem e privkey.pem aparecem em
/opt/nginx-proxy/certs/live/api.exemplo.com/. Ajuste o
ssl_certificate do server block pra apontar pra live/ em vez do
path que coloquei no exemplo (que era simplificado).
Valide a config e recarregue:
docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reloadnginx -t parseia tudo sem aplicar — se aparecer erro de sintaxe ou
arquivo de cert não encontrado, ele recusa o reload e mantém a config
anterior rodando. Zero downtime garantido.
Adicione um cron diário rodando certbot renew --webroot -w /var/www/html
e logo em seguida docker exec nginx-proxy nginx -s reload. Let’s
Encrypt expira em 90 dias — renovar a cada 60 te dá margem se algum
provedor de DNS atrasar.
Verificação
Teste cada domínio externamente:
curl -I https://api.exemplo.com/healthz
Você deve ver HTTP/2 200, server: nginx e os headers do seu
backend. Se vier 502 Bad Gateway, o Nginx alcançou mas o upstream
caiu — docker logs api-backend mostra o que aconteceu. Se vier
504 Gateway Timeout, o backend está vivo mas não respondeu em 60s —
ajuste proxy_read_timeout ou investigue lentidão na aplicação.
Pra confirmar que o IP real do cliente está chegando no backend, logue
X-Forwarded-For na aplicação e bata um curl de outro host. Se aparecer
172.x.x.x em vez do IP público do cliente, algum proxy intermediário
está reescrevendo — ajuste a confiança de proxy no framework do
backend.
Próximos passos
A partir daqui, vale habilitar rate limiting com limit_req_zone pra
endpoints sensíveis (/login, /api/admin), adicionar Brotli como
módulo de compressão em substituição ao gzip, separar logs por domínio
com access_log específico por server block, e mover a config pra um
repositório git versionado.
Se você está colocando isso em produção sério, uma VPS Hostini já vem com Docker pré-instalado, proteção DDoS na borda filtrando ataques antes mesmo de chegar no Nginx, e snapshots automáticos pra reverter qualquer config quebrada em segundos — explore os planos em /vps.
Perguntas frequentes
Por que usar Nginx na frente do Docker em vez de publicar portas diretamente?
Publicar portas (-p 8080:80) funciona pra um container, mas não escala. Com Nginx na frente você usa porta 80/443 única, roteia múltiplos domínios pro mesmo IP, termina TLS num lugar só, centraliza logs de acesso e ganha rate limiting, cache e WAF se quiser. Os containers ficam em network isolada sem expor portas no host.
Preciso do Nginx rodando no host ou também posso usar em container?
Ambos funcionam. Nginx em container é mais limpo (config versionada no compose, fácil de subir em outra máquina) mas exige que o Nginx esteja na mesma network Docker dos backends pra resolver os nomes. Nginx no host é mais simples de debugar mas você precisa apontar pra 127.0.0.1:porta_publicada de cada container. Este guia usa Nginx em container.
Como o Nginx descobre o IP do container backend?
Docker tem DNS interno em cada network user-defined. Quando você define um container com nome 'app' na network 'proxy-net', outros containers na mesma network resolvem 'app' pra IP atual via DNS embutido. Por isso o upstream do Nginx usa o nome do container, não IP fixo — Docker reescreve toda vez que o container reinicia.
Por que preciso de proxy_set_header Host e X-Forwarded-For?
Sem Host, o backend recebe Host: localhost (ou nome interno) e quebra qualquer lógica baseada em domínio (cookies, redirects, multi-tenant). Sem X-Forwarded-For, o backend vê o IP do Nginx (172.x.x.x) em todo request — logs, rate limit e geoip ficam inúteis. São os 2 headers obrigatórios em qualquer reverse proxy.
Como recarrego config do Nginx sem derrubar conexões ativas?
docker exec nginx-proxy nginx -s reload faz hot reload — o master process recompila a config e os workers antigos finalizam requests em vôo antes de morrer. Zero downtime. Antes do reload, sempre rode docker exec nginx-proxy nginx -t pra validar sintaxe — config quebrada com reload deixa o Nginx servindo a anterior, mas se um worker reiniciar do zero ele falha.
Funciona com WebSocket e HTTP/2?
WebSocket exige proxy_set_header Upgrade e Connection no location — sem isso o handshake falha e o cliente cai pra long polling. HTTP/2 funciona out-of-the-box no listen 443 ssl http2, mas só entre cliente e Nginx; do Nginx pro backend continua HTTP/1.1 (que é fine pra 99% dos casos). Pra gRPC, precisa de grpc_pass em vez de proxy_pass.