Configurar o nginx como reverse proxy

Publicado a 16 min de leitura

De proxy_pass ao upgrade de WebSocket: o guia completo do nginx como reverse proxy, incluindo os quatro cabeçalhos sem os quais a sua aplicação vê todos os visitantes como 127.0.0.1.

Quase todas as aplicações modernas ficam à escuta algures numa porta alta: o Node na 3000, um container Docker na 8080, o Gunicorn na 8000, um servidor de aplicações Java na 8443. Isso não se coloca diretamente na Internet. À frente pertence um reverse proxy e, na prática, esse reverse proxy é o nginx.

A configuração básica cabe em cinco linhas. E é exatamente aí que está o problema: as cinco linhas parecem funcionar e, três semanas depois, repara que todos os visitantes surgem no log da aplicação como 127.0.0.1, que o rate limit do início de sessão bloqueia toda a gente ao mesmo tempo e que o email de reposição da palavra-passe contém uma ligação para http://127.0.0.1:3000. Este artigo trata precisamente desses pontos.

O pré-requisito é ter o nginx instalado. Se ainda não for o caso, ajuda o guia instalar o nginx no Debian e no Ubuntu.

O que um reverse proxy faz tecnicamente

O nginx aceita a ligação do visitante e abre em seguida uma segunda ligação, própria, à aplicação. Este é o ponto decisivo, do qual decorrem todos os restantes problemas.

Do ponto de vista da aplicação, o cliente não é o visitante, é o nginx. O IP de origem é 127.0.0.1. O protocolo é http, mesmo que lá fora estivesse a correr HTTPS. O cabeçalho Host é, por omissão, 127.0.0.1:3000 e não app.example.com. E é HTTP/1.0 em vez de HTTP/1.1, razão pela qual os WebSockets falham sempre sem configuração adicional.

Tudo aquilo que a aplicação deve saber sobre o visitante real tem de lhe ser entregue ativamente pelo nginx sob a forma de cabeçalhos HTTP. Por si só, isso não acontece.

A configuração básica e o sítio onde ela deve ficar

O local de armazenamento difere consoante o sistema, e isso confunde-se com regularidade.

Debian e Ubuntu: a configuração fica em /etc/nginx/sites-available/app.conf e é ativada através de um symlink em /etc/nginx/sites-enabled/. Caso contrário, o bloco de servidor predefinido default intercepta todos os pedidos.

AlmaLinux, Rocky, RHEL e Oracle Linux: aí não existe sequer sites-available. O ficheiro vai diretamente para /etc/nginx/conf.d/app.conf e fica assim imediatamente ativo.

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Ativar no Debian e no Ubuntu:

ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx

Executar nginx -t antes de cada recarregamento não é uma cortesia, é uma obrigação. Um systemctl reload com uma configuração defeituosa mantém o processo antigo a correr, mas um reinício posterior do servidor deixa de conseguir arrancar o nginx.

Todos os comandos desta secção pressupõem privilégios de root; caso contrário, use-os com sudo à frente. Isto vale também para os simples comandos de verificação: nginx -t e nginx -T executados como utilizador normal não falham por causa da configuração, falham por causa de um ficheiro que nem sequer têm permissão para escrever. A mensagem [emerg] open() "/run/nginx.pid" failed (13: Permission denied) seguida de configuration file /etc/nginx/nginx.conf test failed não significa, portanto, que a sua configuração esteja avariada.

Esta configuração encaminha os pedidos. Mesmo assim está avariada, e de uma forma que só se manifesta mais tarde.

Os quatro cabeçalhos sem os quais a aplicação fica cega

Estas quatro linhas pertencem a todos os blocos location com proxy_pass:

location / {
    proxy_pass http://127.0.0.1:3000;

    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;
}

Host

Sem esta linha, o nginx define Host: 127.0.0.1:3000. Os sintomas: o Django responde com Invalid HTTP_HOST header e devolve 400. O Laravel e o WordPress geram URLs absolutos para 127.0.0.1, visíveis nos redirecionamentos e em cada email enviado. Um backend que serve vários domínios entrega sempre o conteúdo do domínio errado.

A escolha certa aqui é $host e não $http_host: $host contém o nome de host sem porta e recorre ao server_name caso um cliente não envie qualquer cabeçalho Host. Já $http_host deixa passar aquilo que chegar, indicação de porta incluída.

X-Real-IP

$remote_addr é o IP com o qual o nginx fala efetivamente. Exatamente um endereço, sem vírgulas, sem necessidade de parsing. Para aplicações que conhecem apenas um campo para o IP do cliente, este é o caminho mais simples.

X-Forwarded-For

$proxy_add_x_forwarded_for pega num X-Forwarded-For eventualmente já existente e acrescenta-lhe $remote_addr à direita. Com vários proxies forma-se assim uma cadeia.

E é aqui que está uma falha de segurança que quase nenhum guia refere: o próprio cliente pode enviar um X-Forwarded-For. Se o seu nginx estiver diretamente na Internet, o $proxy_add_x_forwarded_for junta numa mesma lista uma indicação livremente inventada pelo atacante e o IP real do cliente. Se a sua aplicação ler então a primeira entrada como IP do cliente, passa a acreditar em qualquer endereço. Rate limits, bloqueios por IP e lógica geográfica ficam assim contornáveis.

Daqui decorrem duas consequências claras:

  • A cadeia lê-se sempre da direita para a esquerda. A última entrada é a única que o seu próprio proxy escreveu.
  • Se o nginx for a única instância à frente da aplicação, é preferível substituir a cadeia por completo em vez de a acrescentar:
proxy_set_header X-Forwarded-For $remote_addr;

Assim desaparece qualquer histórico falsificado. Só quando existe à frente um load balancer ou um CDN em que confia mesmo é que $proxy_add_x_forwarded_for é a opção certa. Nesse caso deve configurar adicionalmente o módulo realip, para que o próprio nginx conheça o IP verdadeiro e não registe o do elemento anterior:

set_real_ip_from 10.0.0.0/8;
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

A indicação set_real_ip_from é uma whitelist. Sem ela o módulo não tem qualquer efeito e, com uma indicação demasiado ampla como 0.0.0.0/0, torna-se uma porta escancarada.

X-Forwarded-Proto

A aplicação vê uma ligação puramente HTTP, aconteça o que acontecer lá fora. Se este cabeçalho faltar, o que costuma suceder é o seguinte: a aplicação repara que "não há HTTPS" e redireciona para HTTPS, o nginx aceita o pedido, desencripta, volta a encaminhá-lo como HTTP e a aplicação redireciona outra vez. O browser devolve ERR_TOO_MANY_REDIRECTS. Igualmente frequente: os cookies com a flag Secure não são definidos, os inícios de sessão falham sem qualquer mensagem de erro e as páginas carregam imagens e scripts por http://, algo que o browser bloqueia como mixed content.

Use $scheme e não o valor fixo "https". Caso contrário, também o bloco da porta 80 afirma que a ligação foi encriptada.

A prova de que os cabeçalhos chegam mesmo

Em vez de adivinhar, construa um espelho. Este bloco de servidor adicional responde a cada pedido com os cabeçalhos recebidos em texto simples:

server {
    listen 127.0.0.1:9999;
    default_type text/plain;

    location / {
        return 200 "Host: $host\nX-Real-IP: $http_x_real_ip\nX-Forwarded-For: $http_x_forwarded_for\nX-Forwarded-Proto: $http_x_forwarded_proto\nProtocolo: $server_protocol\n";
    }
}

Aponte o proxy_pass, a título de teste, para http://127.0.0.1:9999, recarregue o nginx e abra a página. O que aí aparecer é exatamente aquilo que a sua aplicação teria recebido. Se as quatro linhas estiverem preenchidas e o IP corresponder à sua ligação real, a configuração está correta. Depois volte a remover o bloco de teste.

A aplicação tem, no entanto, de avaliar também os cabeçalhos. O Express precisa de app.set('trust proxy', 1), o Symfony da definição trusted_proxies, o Django de USE_X_FORWARDED_HOST e SECURE_PROXY_SSL_HEADER. Sem este interruptor, as frameworks ignoram os cabeçalhos de propósito, precisamente pelo motivo de spoofing descrito acima.

proxy_pass e a barra que muda tudo

O erro silencioso mais frequente de toda a configuração do nginx. Um único caráter decide qual o caminho que chega ao backend.

ConfiguraçãoPedidoO backend recebe
location /api/ { proxy_pass http://127.0.0.1:3000; }/api/users/api/users
location /api/ { proxy_pass http://127.0.0.1:3000/; }/api/users/users
location /api/ { proxy_pass http://127.0.0.1:3000/v2/; }/api/users/v2/users

A regra: assim que existe qualquer caminho a seguir ao host e à porta, nem que seja apenas uma barra, o nginx substitui a parte do URL que corresponde ao prefixo do location. Sem indicação de caminho, o URL completo é encaminhado sem alterações.

O quadro de erro é característico: a página inicial funciona, mas tudo o que está abaixo de um prefixo devolve 404 e, no log do backend, aparecem caminhos com prefixo duplicado como /api/api/users. Um olhar ao log da aplicação esclarece isto em segundos, ao passo que andar a adivinhar no nginx custa horas.

Há dois casos especiais que produzem mensagens de erro próprias. Num location com expressão regular, a indicação de caminho é proibida, caso contrário o nginx aborta no arranque com nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, inside named location, or inside "if" statement. E assim que usar uma variável em proxy_pass, por exemplo proxy_pass http://$backend;, o nginx deixa de resolver o nome no arranque e passa a resolvê-lo em tempo de execução. Sem uma linha resolver no bloco de servidor, isso acaba em no resolver defined to resolve ... e num 502.

Encaminhar WebSockets

O nginx fala com o backend, por omissão, em HTTP/1.0. O HTTP/1.0 não conhece o mecanismo de upgrade. Por isso, qualquer WebSocket falha com a configuração básica, por muito correto que esteja tudo o resto.

Sintomas típicos: a consola do browser indica WebSocket connection to 'wss://app.example.com/ws' failed, muitas vezes com o acrescento Error during WebSocket handshake: Unexpected response code: 400. O Socket.io recua silenciosamente para long polling, a aplicação limita-se a parecer lenta e, no log de acessos, repetem-se sem fim linhas com /socket.io/?EIO=4&transport=polling.

Primeiro o mapeamento, que decide se uma ligação quer sequer um upgrade. Este pertence ao contexto http, ou seja, de preferência a um ficheiro próprio /etc/nginx/conf.d/websocket.conf:

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

Se este bloco for parar por engano a um bloco server ou location, o nginx deixa de arrancar: nginx: [emerg] "map" directive is not allowed here.

Depois, no bloco location:

location / {
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Porquê o mapeamento e não simplesmente proxy_set_header Connection "upgrade";? Porque assim também os pedidos HTTP perfeitamente normais passam a pedir um upgrade. Alguns backends respondem a isso com 400 e a reutilização de keepalive deixa de existir. O mapeamento envia upgrade apenas quando o cliente pediu efetivamente um e, nos restantes casos, envia close.

O teste: nas ferramentas de programador do browser, o pedido de WebSocket tem de mostrar o estado 101 Switching Protocols. Tudo o resto, em especial 200 ou 400, significa que o upgrade não passou. Na linha de comandos também funciona sem browser:

curl -sSi -o /dev/null -w '%{http_code}\n' \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  http://127.0.0.1/ws

Definir corretamente os timeouts

Se uma ligação cai de forma reproduzível ao fim de exatamente 60 segundos, isso não é coincidência, é o valor predefinido de proxy_read_timeout. Importante para perceber o mecanismo: o valor não limita a duração total do pedido, limita a pausa entre duas operações de leitura. Um download que demora dez minutos, mas fornece dados continuamente, corre sem problemas até ao fim. Um WebSocket em que nada acontece durante 61 segundos é posto fora.

proxy_connect_timeout 10s;
proxy_send_timeout    60s;
proxy_read_timeout    60s;

O proxy_connect_timeout vale apenas para o estabelecimento da ligação e está limitado a 75 segundos. Definir um valor mais alto não serve de nada. Num backend local, 10 segundos já é generoso, e um valor baixo permite-lhe perceber mais depressa que o serviço nem sequer está a correr.

Para WebSockets, o melhor caminho não é proxy_read_timeout 86400s;, é um heartbeat na aplicação que envia um ping a cada 30 segundos. Assim o timeout mantém-se como proteção contra ligações penduradas, em vez de ficar praticamente desligado.

Há mais dois valores predefinidos que atacam com regularidade. O client_max_body_size está em 1 MB, e qualquer upload maior acaba em 413 Request Entity Too Large e na linha de log client intended to send too large body. E, com Server-Sent Events ou respostas em streaming, o visitante fica muito tempo sem receber nada, porque o nginx faz buffering. Nesse caso ajuda proxy_buffering off; no bloco location em causa, de forma dirigida e não global.

Colocar HTTPS à frente

O caminho mais cómodo é o certbot com o plugin para nginx. Lê o bloco de servidor existente, acrescenta a parte de TLS e configura a renovação:

apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d app.example.com

Estes nomes de pacote valem para Debian e Ubuntu. No AlmaLinux, no Rocky Linux e no Oracle Linux, o certbot não está nos repositórios base, e um simples dnf install -y certbot python3-certbot-nginx acaba aí com Error: Unable to find a match. O EPEL é obrigatório:

dnf install -y epel-release
dnf install -y certbot python3-certbot-nginx

No Oracle Linux 9, o pacote EPEL chama-se oracle-epel-release-el9 e, se for caso disso, o repositório tem de ser ativado antes com dnf config-manager --enable ol9_developer_EPEL.

Para vários subdomínios vale a pena consultar os certificados wildcard da Let's Encrypt.

Uma diferença de versões que gera avisos quando se copiam configurações alheias: até ao nginx 1.24, o HTTP/2 ativa-se na linha listen; a partir do nginx 1.25.1 existe uma diretiva própria para isso. O Debian 13 traz o nginx 1.26.3 e quer a grafia nova, ao passo que o Debian 12 (1.22.1), o Ubuntu 24.04 (1.24.0) e o Ubuntu 22.04 (1.18.0) querem a antiga. Do lado da Red Hat, a fronteira passa exatamente no mesmo sítio: o AlmaLinux 10 traz a 1.26.3 e, com ela, a forma nova, enquanto o AlmaLinux 9, o Rocky Linux 9 e o Oracle Linux 9 trazem a 1.20.1 e precisam da antiga.

# nginx a partir da 1.25.1, ou seja, entre outros, o Debian 13
listen 443 ssl;
http2 on;

# nginx até à 1.24, ou seja, Debian 12, Ubuntu 24.04 e 22.04
listen 443 ssl http2;

Se usar a forma antiga num nginx recente, o nginx -t indica: nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead. É apenas um aviso, ou seja, continua a funcionar. Já a forma nova num nginx antigo é um erro de arranque grave: unknown directive "http2".

Para terminar, o ponto mais importante de todo o exercício: o backend não pode estar ele próprio ligado à Internet. Um reverse proxy não serve de nada se http://server-ip:3000 continuar acessível diretamente, porque nesse caso qualquer pessoa define os cabeçalhos como bem entender. Ligue o serviço a 127.0.0.1.

No Docker, esta é uma armadilha particularmente afiada: -p 3000:3000 publica a porta em todos os endereços e cria para isso regras que simplesmente contornam uma firewall ufw. O correto é -p 127.0.0.1:3000:3000. Os detalhes da instalação estão em instalar o Docker no Debian e no Ubuntu.

Se, excecionalmente, fizer proxy para um backend HTTPS, o nginx precisa de uma linha adicional; caso contrário não envia qualquer nome SNI e o outro lado entrega o certificado errado:

proxy_pass https://backend.intern:8443;
proxy_ssl_server_name on;

Quando corre mal: as mensagens na íntegra

O primeiro sítio a consultar é sempre tail -f /var/log/nginx/error.log. A página de erro do browser não diz nada, o log diz tudo.

Uma pequena nota prévia, para que já a primeira linha não confunda: no AlmaLinux, no Rocky Linux e no Oracle Linux, o ficheiro /var/log/nginx/error.log ainda não existe logo a seguir à instalação, só nasce no primeiro arranque do nginx. O tail responde então com cannot open ... No such file or directory. No Debian e no Ubuntu, o pacote cria access.log e error.log logo na instalação. Uma forma robusta que funciona nos dois mundos:

tail -n 50 /var/log/nginx/error.log 2>/dev/null || journalctl -u nginx -n 50 --no-pager
Mensagem no error.logSignificado e solução
connect() failed (111: Connection refused) while connecting to upstreamNada está à escuta na porta de destino. Verifique o estado do serviço e confirme com ss -ltnp se a porta e o endereço correspondem à linha proxy_pass. Se faltar o ss, ele vem no Debian e no Ubuntu com o pacote iproute2 e, na família Red Hat, com o iproute (aí sem o 2 no nome).
connect() failed (113: No route to host)Uma firewall entre o nginx e o backend está a bloquear. Com containers, é muitas vezes uma rede errada.
connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstreamNo AlmaLinux, Rocky, RHEL e Oracle Linux é quase sempre o SELinux. Comprova-se com ausearch -m AVC -ts recent e resolve-se com setsebool -P httpd_can_network_connect 1. No Debian e no Ubuntu isto não acontece.
upstream timed out (110: Connection timed out) while reading response header from upstreamDá origem a um 504. O backend responde demasiado devagar. Vá primeiro ver lá e só depois aumente o proxy_read_timeout.
upstream prematurely closed connection while reading response headerDá origem a um 502. O processo do backend morreu durante o pedido, muitas vezes às mãos do OOM killer. Ver configurar swap.
upstream sent too big header while reading response header from upstreamCabeçalhos de resposta demasiado grandes, um clássico com cookies numerosos ou longos. Defina proxy_buffer_size 32k; e proxy_buffers 8 32k;.
no live upstreams while connecting to upstreamNum bloco upstream, todos os destinos foram marcados como indisponíveis. Controle o comportamento de verificação de saúde através de max_fails e fail_timeout.

Para os casos especiais em torno do 502 existe um guia próprio: resolver o nginx 502 Bad Gateway. Se o seu backend ainda não for um serviço que arranque de forma limpa, vale a pena antes criar um serviço systemd.

Como reconhece que está mesmo a funcionar

Cinco verificações que, em conjunto, são conclusivas:

  1. O nginx -t indica syntax is ok e test is successful.
  2. O nginx -T mostra a configuração completa já montada. Procure aí por proxy_set_header e conte: os quatro cabeçalhos têm de estar em cada bloco location relevante. Um proxy_set_header num bloco interior anula todos os cabeçalhos herdados do bloco exterior, e essa é a causa mais frequente do clássico "mas eu tinha isto definido".
  3. No log da aplicação aparece o IP real do visitante e não 127.0.0.1.
  4. O bloco de servidor espelho apresentado acima devolve os quatro valores preenchidos, com X-Forwarded-Proto: https no acesso por HTTPS.
  5. Para WebSockets: estado 101 nas ferramentas de programador, e a ligação sobrevive a mais de 60 segundos de silêncio.

Ajuda ainda um formato de log que registe também o IP encaminhado. Assim vê de imediato se o nginx e a aplicação se referem ao mesmo endereço:

log_format proxied '$remote_addr xff="$http_x_forwarded_for" '
                   'host=$host "$request" $status $body_bytes_sent '
                   'upstream=$upstream_addr rt=$request_time urt=$upstream_response_time';

access_log /var/log/nginx/app.access.log proxied;

Os dois valores de tempo no fim valem ouro: $request_time é a duração total do ponto de vista do visitante e $upstream_response_time é a duração da resposta do backend. Se ambos estiverem próximos um do outro, o backend é lento. Se houver uma diferença grande, a culpa é da ligação até ao cliente ou do buffering.

Fica assim montado um reverse proxy que não se limita a tornar a aplicação acessível, mas lhe entrega também tudo aquilo que ela precisa de saber sobre os seus visitantes. Se montar esta configuração num sistema acabado de instalar, a checklist para novos servidores root é um bom ponto de partida para tudo o que vem antes.

Perguntas frequentes

Porque é que a minha aplicação vê 127.0.0.1 em vez do IP real do visitante?
Porque o nginx abre uma segunda ligação, própria, ao backend. Do ponto de vista da aplicação, o cliente passa a ser o nginx. O endereço verdadeiro tem de ser entregue ativamente como cabeçalho, através de proxy_set_header X-Real-IP $remote_addr e proxy_set_header X-Forwarded-For. Além disso, a framework tem de permitir a respetiva avaliação, por exemplo com app.set('trust proxy', 1) no Express ou SECURE_PROXY_SSL_HEADER no Django.
Qual é a diferença entre X-Real-IP e X-Forwarded-For?
O X-Real-IP contém exatamente um endereço, o do cliente diretamente ligado. O X-Forwarded-For é uma cadeia separada por vírgulas, à qual cada proxy acrescenta o seu antecessor. A cadeia tem de ser lida sempre da direita para a esquerda, porque só a última entrada vem com segurança do seu próprio proxy. Tudo o que estiver mais à esquerda pode ter sido falsificado por um cliente.
Porque é que os meus WebSockets não funcionam por trás do nginx?
O nginx fala com o backend, por omissão, em HTTP/1.0, e o HTTP/1.0 não conhece qualquer mecanismo de upgrade. São necessários proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade e proxy_set_header Connection $connection_upgrade, sendo que a variável $connection_upgrade tem de ser definida por um bloco map no contexto http. Sabe que resultou quando o browser mostra o estado 101 Switching Protocols.
Porque é que a minha ligação cai sempre ao fim de exatamente 60 segundos?
60 segundos é o valor predefinido de proxy_read_timeout. O valor não limita a duração total, limita a pausa entre duas operações de leitura. Para ligações de longa duração pode aumentá-lo, mas em WebSockets é preferível um heartbeat na aplicação, para que o timeout se mantenha como proteção.
O que provoca a barra no fim de proxy_pass?
Se existir um caminho a seguir ao host e à porta, nem que seja uma única barra, o nginx substitui a parte do URL que corresponde ao prefixo do location. Com location /api/ e proxy_pass http://127.0.0.1:3000/, o /api/users chega ao backend como /users. Sem a barra, o /api/users chega inalterado. O quadro de erro típico de uma escolha errada são 404 abaixo do prefixo ou caminhos duplicados como /api/api/users.
Porque é que recebo um 502 com Permission denied no AlmaLinux?
Na família Red Hat, o SELinux está ativo por omissão e proíbe ligações de rede de saída ao contexto do servidor web. No error.log aparece então connect() failed (13: Permission denied) while connecting to upstream. A solução é setsebool -P httpd_can_network_connect 1, e a alteração faz efeito de imediato, sem recarregar o nginx. No Debian e no Ubuntu o problema não ocorre.

nginx Reverse Proxy proxy_pass WebSocket X-Forwarded-For HTTPS Linux Administração de servidores