kelvin@cluxnei.dev:~/blog

kelvin@cluxnei:~/blog$ cat 2gb/03-a-porta-de-entrada.md

2026-09-20 PT ~20 min de leitura read in English

Um IP, uma porta 443, várias aplicações

No fim da parte 2 sua aplicação rodava, voltava sozinha e tinha teto de memória. E continuava invisível, porque escuta em 127.0.0.1:3000 e ninguém digita porta 3000 no navegador. Falta a peça que recebe o mundo inteiro numa porta só e distribui. Esta parte é sobre ela, com quatro experimentos medidos, incluindo um que explica por que a conta de 2 GB fecha.

Neste texto

  1. Por que um proxy, mesmo com uma app só
  2. Vários sites, um IP
  3. A ordem de match não é de cima para baixo
  4. O que a sua aplicação deixa de saber
  5. WebSocket e a conexão que não pode ser fechada
  6. Quem serve o arquivo estático
  7. 2000 conexões, 0,8 MB
  8. Onde o TLS termina
  9. Os limites que ninguém configura
  10. O checklist

Por que um proxy, mesmo com uma app só

A pergunta honesta é essa. Se você tem uma aplicação, por que não deixá-la escutando direto na porta 443 e acabou?

Porque a porta 443 é um recurso único da máquina e tudo que precisa dela vai brigar. No momento em que aparecer a segunda aplicação, ou um segundo domínio, ou uma pasta de arquivos estáticos, você vai ter que colocar alguma coisa na frente de qualquer jeito. Mas mesmo com uma app só, o proxy resolve cinco coisas que você teria que escrever você mesmo:

Vários sites, um IP

Um IP só, e o navegador diz no cabeçalho Host qual site ele quer. O nginx escolhe o bloco server pelo server_name. Um arquivo por site em /etc/nginx/sites-available, com link simbólico em sites-enabled:

server { listen 443 ssl; server_name app.meusite.dev; location / { proxy_pass http://127.0.0.1:3000; } } server { listen 443 ssl; server_name site.meusite.dev; root /var/www/site; }

Duas coisas que economizam uma tarde:

O primeiro bloco que escuta numa porta é o servidor padrão dela. Quem chegar com um Host desconhecido, ou direto pelo IP, cai nesse bloco. Se isso te incomoda, declare um bloco explícito com default_server devolvendo 444, que fecha a conexão sem responder nada.

E nginx -t antes de todo reload. Ele valida a configuração inteira sem aplicar. O reload com configuração quebrada não derruba o nginx, ele recusa e continua com a versão antiga, mas você fica achando que aplicou alguma coisa.

A ordem de match não é de cima para baixo

Esta seção é a que mais causa dano, porque o sintoma não aponta para a causa.

A intuição de todo mundo é que o nginx lê os blocos location na ordem do arquivo e usa o primeiro que casar. Está errado. A ordem real é por tipo de location, e o arquivo não tem nada a ver com isso:

1. location = /exato casamento exato, ganha de todos 2. location ^~ /prefixo prefixo que bloqueia regex 3. location ~ regex regex, na ordem do arquivo location ~* regex idem, ignorando maiúsculas 4. location /prefixo prefixo comum, o perdedor

Leia o item 3 e o item 4 juntos. Uma regex ganha de um prefixo comum, esteja ela onde estiver no arquivo. E agora considere a configuração mais comum do mundo, que é cache longo para assets mais um proxy para a aplicação:

location ~ \.js$ { expires 1y; } location /app/ { proxy_pass http://127.0.0.1:8100; }

Montei exatamente isso num nginx de verdade, com um backend que responde qualquer caminho, e pedi /app/client.js. Coloquei um cabeçalho em cada bloco para ver quem pegou:

# com location /app/ HTTP/1.1 404 Not Found X-Quem-Pegou: regex .js # com location ^~ /app/ HTTP/1.1 200 OK X-Quem-Pegou: prefixo /app/ corpo: veio do backend: /app/client.js

Na primeira versão o arquivo dá 404. A regex \.js$ venceu, o nginx foi procurar client.js no disco, não achou, e devolveu 404. O proxy nunca foi consultado. A aplicação carrega, o HTML chega, e só os scripts somem. Nenhum log da aplicação registra nada, porque a requisição não chegou nela.

Dois caracteres consertam. ^~ quer dizer se este prefixo casar, pare aqui e não considere nenhuma regex.

A regra prática: toda location de prefixo que faz proxy_pass leva ^~. Sem exceção. O custo é zero e o dia em que alguém adicionar uma regex de assets no arquivo, nada quebra.

O que a sua aplicação deixa de saber

Atravessar o proxy custa informação. A aplicação passa a conversar com o nginx, não com o navegador, e por padrão ela perde quase tudo sobre quem chamou.

Subi um backend que imprime os cabeçalhos que recebe e passei por um proxy_pass sem mais nada configurado, pedindo o site meusite.dev:

# proxy_pass sozinho Host: 127.0.0.1:8100 cliente TCP visto pelo backend: 127.0.0.1

É só isso que chega. Repare no Host: a aplicação acredita que ela é 127.0.0.1:8100. Todo link absoluto que ela montar, todo redirecionamento, todo e-mail de confirmação de cadastro vai apontar para http://127.0.0.1:8100. E o IP do visitante é sempre 127.0.0.1, então seu rate limit por IP, seu log de acesso e seu antifraude enxergam o mundo inteiro como uma pessoa só.

Com as quatro linhas que faltavam:

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;

# mesmo pedido, agora Host: meusite.dev X-Real-IP: 127.0.0.1 X-Forwarded-For: 127.0.0.1 X-Forwarded-Proto: http

X-Forwarded-Proto merece um parágrafo só dele, porque é a origem de um bug que consome tardes inteiras. O TLS termina no nginx. Do nginx para a sua aplicação o tráfego é HTTP puro, e é isso mesmo que se espera. Só que a aplicação, olhando a própria conexão, conclui com razão que está em HTTP, e gera link http:// num site https://. O navegador então bloqueia conteúdo misto, ou o HSTS força HTTPS, o servidor redireciona, e você ganha um loop de redirecionamento sem causa aparente.

Mandar o cabeçalho é metade do conserto. A outra metade é a aplicação acreditar nele, e isso é explícito em todo framework:

stacko que ligar
Expressapp.set('trust proxy', 1)
LaravelTrustProxies, ou fastcgi_param HTTPS on no FPM
DjangoSECURE_PROXY_SSL_HEADER
Railsconfig.force_ssl com assume_ssl

Cuidado com a confiança. Só confie em X-Forwarded-* se o único caminho até a sua aplicação for o proxy. Se ela também escuta em 0.0.0.0, qualquer um manda o cabeçalho que quiser e o seu rate limit por IP vira decoração. É o outro motivo, além do da parte 2, para escutar só no 127.0.0.1.

WebSocket e a conexão que não pode ser fechada

Nos testes acima apareceu um detalhe que vale explicar: o backend recebeu Connection: close. Por padrão, o nginx conversa com a aplicação em HTTP/1.0 e fecha a conexão a cada requisição, mesmo que o navegador esteja em keep-alive do lado de fora.

Para tráfego normal isso custa um handshake TCP por requisição. Para WebSocket, mata: a conexão precisa ser promovida, e uma conexão que o proxy fecha não tem como ser promovida a nada.

location ^~ /ws/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }

As três primeiras linhas fazem o upgrade acontecer. A quarta é a que quase sempre falta: o padrão de proxy_read_timeout é 60 segundos, e uma conexão WebSocket ociosa por mais de um minuto é derrubada pelo proxy. O sintoma é um chat que desconecta sozinho quando ninguém fala, e a culpa cai no cliente, que não tem nada com isso.

Quem serve o arquivo estático

Quase todo framework consegue servir a pasta public. A pergunta é se deveria.

Medi com um arquivo de 120 KB, tamanho de bundle realista, 4000 requisições com 20 conexões simultâneas, duas rodadas de cada:

quem serverodada 1rodada 2custo em memória
nginx, direto do disco2827 req/s2527 req/snenhum a mais
nginx encaminhando para o Node1529 req/s1640 req/s28,6 MB do processo

Cerca de 1,7 vez mais rápido, e sem os 28,6 MB. O cliente de teste rodava na mesma máquina, então os valores absolutos dizem pouco. A razão entre eles é o que importa, e ela vem de uma diferença estrutural: o nginx entrega o arquivo com sendfile, que copia do page cache para o socket dentro do kernel, sem os dados passarem por espaço de usuário. O Node lê o arquivo para a memória dele, monta a resposta e escreve. Na segunda vez em diante os dois leem do cache, e ainda assim o caminho mais curto ganha.

Na prática isso é uma location:

location ^~ /assets/ { alias /srv/minhaapp/current/public/assets/; expires 1y; add_header Cache-Control "public, immutable"; access_log off; }

O immutable serve para arquivo com hash no nome, e diz ao navegador para não revalidar nem quando o usuário aperta recarregar. Se o seu build não coloca hash nos nomes, não use um ano de cache, porque você não vai conseguir publicar correção nenhuma.

2000 conexões, 0,8 MB

Aqui está a medição que sustenta a série inteira.

O nginx não usa uma thread por conexão. Cada worker é um processo só, com um laço de eventos em cima de epoll, e uma conexão ociosa é um descritor de arquivo mais algumas centenas de bytes de estado. Dizer isso é fácil. Eu abri 2000 conexões keep-alive simultâneas contra um nginx real e medi a memória própria dele antes e durante:

antes: 1 conexão nginx com 5,9 MB durante: 2001 conexões nginx com 6,7 MB 2000 conexões custaram 0,8 MB, ou cerca de 0,4 KB cada

Compare com o modelo oposto, que é o do PHP-FPM da parte 1: um processo por requisição simultânea, a 25 ou 40 MB cada. Atender 2000 requisições ao mesmo tempo nesse modelo pediria dezenas de gigabytes. Não é uma questão de ajuste fino, é a arquitetura.

Essa é a razão de a conta de 2 GB fechar. A camada que fala com o mundo, que é a que recebe todo pico e toda conexão lenta, é a mais barata da pilha. Os processos caros ficam atrás dela, em número pequeno e controlado.

Existe um teto, e vale saber onde ele está:

worker_processes auto; # um por vCPU. Com 4 vCPUs, 4 workers events { worker_connections 768; # padrão do Ubuntu, por worker } # teto = 4 x 768 = 3072 conexões # cada requisição proxiada usa duas: navegador e backend

Numa VPS de 2 GB dá para subir worker_connections para 4096 sem dor, porque o custo por conexão é aquele 0,4 KB. O que trava antes disso é o limite de descritores de arquivo do processo, que se ajusta com LimitNOFILE na unidade do systemd, exatamente como na parte 2.

Onde o TLS termina

Termina no nginx. O certificado é dele, a negociação é dele, e a partir dali para dentro o tráfego é HTTP puro em 127.0.0.1, que é a razão de toda a seção dos cabeçalhos existir.

Certificado gratuito com renovação automática:

apt install certbot python3-certbot-nginx certbot --nginx -d meusite.dev -d www.meusite.dev # a renovação já vem como timer do systemd: systemctl list-timers | grep certbot certbot renew --dry-run

O certbot edita o seu arquivo do nginx e instala um timer. Vale rodar o --dry-run uma vez, porque descobrir que a renovação nunca funcionou noventa dias depois, com o site fora do ar, é uma experiência evitável.

O redirecionamento de HTTP para HTTPS fica num bloco separado, e a porta 80 precisa continuar aberta para a validação da renovação funcionar:

server { listen 80; server_name meusite.dev www.meusite.dev; location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } }

Sobre HSTS, uma frase de aviso. O cabeçalho Strict-Transport-Security manda o navegador nunca mais tentar HTTP naquele domínio, e ele é lembrado pelo tempo do max-age. É uma boa ideia, e é uma porta que tranca por fora: enquanto o prazo não vence, nenhum visitante que já passou pelo site consegue acessá-lo sem TLS válido, nem que você queira. Comece com um max-age curto.

Os limites que ninguém configura

Quatro linhas que evitam quatro incidentes:

client_max_body_size 10m; # padrão é 1m. Upload maior devolve 413 client_body_timeout 15s; send_timeout 15s; proxy_read_timeout 60s; # o tempo que o proxy espera a sua app gzip on; gzip_types text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; # abaixo disso comprimir custa mais do que rende

O client_max_body_size padrão de 1 MB é o campeão de chamados. O usuário tenta subir uma foto de celular, recebe 413, e não existe nenhum registro disso no log da aplicação, porque o nginx recusou antes de repassar.

E o proxy_read_timeout de 60 segundos combinado com uma rota lenta gera 504 que some do log da aplicação por motivo oposto: ela respondeu, só que tarde demais e ninguém estava mais ouvindo.

O checklist

itemcomo conferir
a configuração é válidanginx -t antes de todo reload
prefixo com proxy tem ^~grep -n "location" no arquivo
os quatro cabeçalhos estão lágrep proxy_set_header
a app confia no proxygere um link absoluto e veja o esquema
estático não passa pela appcurl -I e olhe o Server
a renovação do TLS funcionacertbot renew --dry-run
upload grande passamande um arquivo maior que 1 MB
ninguém alcança a app diretocurl http://SEU_IP:3000 de fora

O que vem na parte 4

A máquina está lida, o processo está supervisionado e a porta de entrada distribui o tráfego. Falta a pergunta que você vem adiando desde o começo: como o código chega até lá.

A parte 4 é o deploy. O que você sobe não é a sua pasta de trabalho, e a diferença entre as duas coisas é a classe de bug mais traiçoeira que existe, porque ela nunca reproduz local. Releases numeradas com troca atômica, rollback em um comando, onde buildar quando o build não cabe na máquina, segredos com permissão, e a conta de max_connections do Postgres que a parte 1 deixou em aberto.

kelvin@cluxnei:~/blog$