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

O que você precisa antes de começar

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.

Porta HTTP 80
Porta HTTPS 443
Network Docker proxy-net
Diretório de config /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.

01

Crie a network proxy-net:

docker network create proxy-net

Isso aparece em docker network ls como driver bridge. Você pode checar com docker network inspect proxy-net — vai estar vazia por enquanto.

02

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-proxy

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

03

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: true

external: 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.

04

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

05

Suba o Nginx:

docker compose up -d
docker compose logs nginx

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

06

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: true

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

07

Valide que o Nginx enxerga o backend pelo nome:

docker exec nginx-proxy ping -c 2 api-backend

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

08

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.

Replique pra cada domínio

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.

09

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-email

Os 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).

10

Valide a config e recarregue:

docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reload

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

Renovação automática

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.

Tópicos:
Próximos passos Cloud Ryzen com NVMe e proteção DDoS sempre ativa.Coloque em produção numa VPS Hostini →
Esse tutorial foi útil?
Falar no WhatsApp