Zig por trás de Nginx: Proxy Reverso, TLS, Load Balancing e WebSockets em Produção

Um binário Zig que responde HTTP sozinho é só metade do caminho para produção. No mundo real, quase nenhum serviço fica exposto diretamente na porta 443. Na frente dele costuma existir um proxy reverso — na maioria das vezes Nginx — responsável por terminação TLS, compressão, cache de estáticos, rate limiting de borda, roteamento entre várias réplicas e isolamento contra a internet hostil. Colocar um serviço Zig atrás de Nginx é simples, mas tem um conjunto de armadilhas recorrentes: timeout do upstream menor que o do cliente, WebSockets sem Upgrade, balanceamento para um socket já morto, headers de confiança ignorados e TLS terminado duas vezes.

Este guia mostra o desenho que se repete quando um binário Zig precisa ficar por trás de Nginx em produção: upstream, terminação TLS, balanceamento de carga, WebSockets, timeouts coerentes, backpressure, headers de segurança e troubleshooting dos erros mais comuns. O foco não é defender uma arquitetura específica, e sim deixar explícito o contrato entre o proxy e o serviço Zig para que a operação fique previsível.

Este artigo complementa servidor HTTP em Zig para produção, servidor HTTP da stdlib, Zig com systemd: serviço Linux em produção, TLS, HTTPS e mTLS em Zig, rate limiting com token bucket e Server-Sent Events em Zig. A mentalidade é a mesma que aparece nesses guias: fronteira pequena, contrato explícito e comportamento previsível sob falha.

Por que um proxy reverso na frente de Zig

Zig consegue servir HTTP com performance excelente e controle fino de memória, mas isso não significa que o binário deva assumir todas as responsabilidades de borda. Existem tarefas para as quais Nginx (ou HAProxy, Traefik, Caddy) foi construído e auditado por décadas:

  • Terminação TLS com renovação automática de certificados (Let’s Encrypt, ACM, mTLS interno).
  • HTTP/2 e HTTP/3 sem custo de implementação no serviço.
  • Compressão gzip/brotli com buffers ajustados.
  • Cache de estáticos (CSS, JS, fontes, imagens) sem tocar no aplicativo.
  • Rate limiting e IP allowlist/denylist na borda, antes do tráfego chegar ao upstream.
  • Balanceamento de carga entre várias réplicas do mesmo binário.
  • Isolamento de rede: o serviço Zig escuta apenas em 127.0.0.1 ou num unix socket privado.

A divisão de responsabilidades é o ponto. O binário Zig faz o que ele sabe fazer bem — lógica de negócio, I/O explícito, alocação controlada, baixa latência — e o proxy assume a parte de borda, onde décadas de patches de segurança e otimizações já foram pagas.

O erro comum é o oposto: achar que Zig “como é rápido” dispensa o proxy. O resultado é um serviço exposto diretamente, sem renovação de TLS, sem cache, sem redundância, sem proteção contra tráfego malicioso. Não é uma questão de velocidade; é uma questão de operação.

O upstream: como o Nginx enxerga o serviço Zig

A peça central de qualquer bloco Nginx que faz proxy reverso é o upstream. Ele descreve para onde o proxy vai mandar a requisição. Para um serviço Zig rodando na porta 8080 da mesma máquina, o upstream é direto:

upstream zig_backend {
    server 127.0.0.1:8080 max_fails=3 fail_timeout=10s;
    keepalive 32;
}

Dois detalhes merecem atenção. Primeiro, max_fails e fail_timeout definem o comportamento quando uma réplica para de responder: depois de 3 falhas em 10 segundos, o Nginx considera o upstream indisponível e, se houver outras réplicas, manda o tráfego para elas. Sem isso, uma répia travada continua recebendo requisições até um humano intervir. Segundo, keepalive 32 mantém um pool de conexões TCP reutilizadas para o upstream, evitando o custo de abrir um socket novo a cada requisição. Isso só funciona se o serviço Zig também souber manter conexões keep-alive; a stdlib std.http.Server suporta, mas vale validar com um teste de carga simples.

Quando existem várias réplicas (típico em deploy com systemd em mais de uma instância, ou em containers orquestrados), o upstream lista todas:

upstream zig_backend {
    least_conn;
    server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.13:8080 max_fails=3 fail_timeout=10s backup;
    keepalive 64;
}

A diretiva least_conn encaminha cada nova requisição para a réplica com menos conexões ativas. backup marca uma réplica que só recebe tráfego quando todas as outras falham — útil para manter uma instância de reserva quente.

Proxy reverso e repasse de headers

O bloco server faz o casamento entre a requisição externa e o upstream. A regra de ouro é: o serviço Zig precisa saber de onde a requisição veio (IP real, host original, esquema original), porque sem isso ele toma decisões erradas sobre redirecionamentos, logs e CSRF. O Nginx entrega essas informações em headers X-Forwarded-* e X-Real-IP:

server {
    listen 443 ssl http2;
    server_name api.exemplo.com.br;

    ssl_certificate     /etc/letsencrypt/live/api.exemplo.com.br/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.exemplo.com.br/privkey.pem;

    location / {
        proxy_pass http://zig_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 X-Forwarded-Host  $host;

        proxy_connect_timeout 2s;
        proxy_send_timeout    30s;
        proxy_read_timeout    30s;

        proxy_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers     8 16k;
    }
}

Vale revisar cada parte. proxy_http_version 1.1 é obrigatório para manter a conexão keep-alive com o upstream e também para WebSockets. Os headers X-Forwarded-* precisam ser repassados explicitamente — por padrão o Nginx não os envia. proxy_connect_timeout é o tempo limite para abrir a conexão TCP com o upstream; se o serviço Zig demora mais que 2 segundos para aceitar a conexão, algo está muito errado e o Nginx devolve 502. proxy_send_timeout e proxy_read_timeout são os limites entre duas operações de leitura/escrita — não é o tempo total da requisição, e sim o tempo máximo entre dois pacotes.

No lado Zig, o serviço precisa ler esses headers para reconstruir a URL original. Sem isso, qualquer redirecionamento que o serviço emita vai apontar para http:// em vez de https://, quebrando fluxos de login e OAuth. O padrão seguro é confiar em X-Forwarded-Proto apenas quando a requisição veio do Nginx (caso contrário, qualquer cliente pode forjar o header).

Load balancing e coerência de timeout

O erro de timeout mais comum em deploy com proxy é a incoerência entre cliente, proxy e serviço. Se o cliente espera até 60 segundos, o proxy corta em 30 e o serviço corta em 10, todo mundo descobre o problema só quando aparece. A regra prática é: timeout do serviço ≤ timeout do proxy ≤ timeout do cliente. Assim, quem cancela a requisição primeiro é sempre o serviço Zig, que sabe o que fazer com o cancelamento (liberar recursos, registrar métrica, devolver erro estruturado).

Para rotas que sabidamente demoram mais — upload de arquivos, exportação de relatórios, webhooks de processamento — os timeouts precisam ser maiores. Em vez de aumentar o proxy_read_timeout global, é mais limpo ter um location dedicado:

location /exportar/ {
    proxy_pass http://zig_backend;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    proxy_request_buffering off;
}

proxy_request_buffering off é importante para uploads grandes: sem ele, o Nginx bufferiza o corpo inteiro no disco antes de repassar ao upstream, o que dobra o tempo de upload e lota o disco temporário. Para streaming de corpo, desligar o buffering é essencial — e combina com o padrão discutido em validação de uploads com streaming em Zig.

Backpressure é o outro lado da mesma moeda. Quando o serviço Zig satura e o socket enche, o Nginx precisa ver o erro e parar de mandar tráfego, em vez de enfileirar requisições. A combinação max_fails=3 fail_timeout=10s no upstream com um proxy_next_upstream error timeout http_502 http_503 http_504; no location faz exatamente isso: diante de um erro, o proxy tenta a próxima réplica; sem réplica disponível, devolve 502/503 ao cliente em vez de espernear. Para detalhes de como o serviço deve reagir quando está sob pressão, rate limiting com token bucket e circuit breaker, timeout e retry cobrem o desenho interno.

WebSockets por trás do proxy

WebSockets usam o mecanismo HTTP Upgrade. Por padrão, o Nginx não repassa esse header, e a conexão morre com erro 400 ou fecha logo depois do handshake. O bloco correto para WebSockets é:

location /ws/ {
    proxy_pass http://zig_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";

    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_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Dois pontos críticos. Primeiro, Connection "upgrade" (com aspas, literal) é o que ativa o modo tunnel do proxy — sem ele, o Nginx trata a conexão como HTTP comum e corta após a primeira resposta. Segundo, proxy_read_timeout 3600s evita que o Nginx feche conexões WebSocket ociosas após 60 segundos (o default). Para clientes que mantêm conexões longas com pouco tráfego (painéis de monitoramento, chat), esse timeout precisa cobrir o intervalo entre heartbeats.

O serviço Zig, no lado do socket, precisa responder ao ping do cliente com pong e fechar conexões que não falam há mais que o heartbeat combinado. Caso contrário, o proxy mantém sockets zumbis abertos até o limite de file descriptors do processo. Os detalhes de implementação do servidor WebSocket em Zig — handshake, frames texto, ping/pong, limites de payload — estão em Zig e WebSockets: servidor real-time e em cliente WebSocket em Zig.

Unix socket em vez de porta TCP

Para deploys de uma única máquina, um socket de domínio Unix entre o Nginx e o serviço Zig costuma ser melhor que uma porta TCP: não passa pela pilha de rede, não consume porta, e o Nginx consegue reaproveitar conexões mais rápido. O serviço Zig abre o socket, e o Nginx aponta para o arquivo:

upstream zig_backend {
    server unix:/run/zig/app.sock max_fails=3 fail_timeout=10s;
    keepalive 32;
}

Os cuidados são dois. O socket precisa pertencer ao usuário do Nginx (ou a um grupo compartilhado) e ter permissão de leitura/escrita — chown :www-data /run/zig/app.sock && chmod 660 /run/zig/app.sock. E o diretório /run/zig/ precisa existir após reboot, o que se resolve com um RuntimeDirectory=zig na unit do systemd, conforme detalhado em Zig com systemd: serviço Linux em produção.

Headers de segurança e HSTS

Como o Nginx é a borda, é nele que entram os headers de segurança que valem para todas as rotas. O serviço Zig não precisa repeti-los:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options    "nosniff" always;
add_header X-Frame-Options           "SAMEORIGIN" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;

HSTS (Strict-Transport-Security) só faz sentido depois que o TLS está rodando estável e todos os subdomínios relevantes também servem HTTPS — caso contrário, o header trava visitantes em domínios ainda não migrados. O sufixo always garante que o header seja enviado mesmo em respostas 4xx/5xx, sem o qual um atacante poderia forjar uma página de erro sem proteção. Os fundamentos de TLS — geração de chaves, renovação, mTLS interno entre serviços — estão cobertos em TLS, HTTPS e mTLS em Zig.

Troubleshooting: 502, 504 e conexões recusadas

Quase todo incidente “meu serviço Zig caiu atrás do Nginx” se resolve olhando o código de erro do proxy.

502 Bad Gateway significa que o Nginx não conseguiu falar com o upstream. Causas típicas: o serviço Zig não está rodando (systemctl status zig-app), ele está ouvindo em 127.0.0.1 mas o Nginx manda para 0.0.0.0, ou o socket unix tem permissão errada. O diagnóstico direto é curl -v http://127.0.0.1:8080/ a partir da própria máquina: se esse curl falhar, o problema está no serviço, não no proxy.

504 Gateway Timeout significa que o Nginx conectou no upstream, mas o serviço não respondeu dentro do proxy_read_timeout. Causas: o serviço está travado em I/O (banco lento, lock disputado), a rota demora mais que o timeout configurado, ou o serviço fez o trabalho mas travou no meio do write da resposta. O diagnóstico é olhar o log do serviço Zig naquele momento e comparar com o proxy_read_timeout do location. Se o serviço precisa de mais tempo, o caminho é subir o timeout apenas para aquela rota, não globalmente.

Conexões recusadas intermitentes em deploy com várias réplicas geralmente indicam que uma réplica reiniciou e o healthcheck do Nginx não percebeu a tempo. A combinação max_fails + fail_timeout precisa casar com o intervalo de restart esperado do serviço. Se o serviço reinicia rápido (menos de fail_timeout), o Nginx ainda manda tráfego para ele durante a janela morta e devolve 502 ao cliente. Uma política de healthcheck ativa (módulo healthcheck ou um location /healthz interno) reduz essa janela.

HTTP/2 quebrado após upgrade do Nginx costuma ser direto: a diretiva listen 443 ssl http2; em versões antigas do Nginx virou http2 on; separado em versões novas. A sintaxe mista compila mas ignora o HTTP/2 silenciosamente. O diagnóstico é curl -v --http2 https://api.exemplo.com.br/ e olhar a linha de negotiation.

Checklist de produção

Antes de declarar o deploy pronto:

  • O serviço Zig escuta apenas em 127.0.0.1 ou num unix socket, nunca em 0.0.0.0:porta.
  • O upstream tem max_fails e fail_timeout definidos, mesmo com uma única réplica.
  • Os timeouts do proxy são coerentes com os do serviço e do cliente.
  • Os headers X-Forwarded-* estão sendo repassados e o serviço Zig lê X-Forwarded-Proto para decidir esquema.
  • HSTS só está ligado depois que todo o domínio serve HTTPS de forma estável.
  • WebSockets têm um location dedicado com Connection "upgrade" e proxy_read_timeout alto.
  • Existe um location /healthz que o Nginx (ou o orquestrador) usa para saber que a réplica está viva.
  • Logs de acesso e erro do Nginx estão sendo coletados, com log_format incluindo $upstream_response_time e $upstream_addr.
  • Há um teste de failover manual: matar uma réplica e confirmar que o cliente vê erro zero (ou um 502 breve seguido de recuperação).

O serviço Zig por trás de Nginx é uma combinação comum, estável e barata. O segredo não é nenhuma configuração exótica: é deixar o contrato entre proxy e upstream explícito, alinhar os timeouts, repassar os headers certos e revisar o checklist antes de abrir o tráfego. Feito isso, a operação fica previsível — e quando algo quebra, o diagnóstico sai em minutos a partir dos códigos de erro do proxy, sem adivinhação.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.