29 out 2026 · linux, nginx, web · 5 min
Como servir um site estático com nginx do zero
Um site estático precisa de três coisas do servidor: entregar o arquivo certo, comprimir o que for texto e dizer ao navegador por quanto tempo pode guardar cada coisa. O arquivo abaixo faz as três num único nginx.conf, sem módulo extra e sem painel. Testei num contêiner isolado com nginx 1.31.6: nginx -t passou, um asset com ETag voltou HTTP 304 ao revalidar, e um CSS de 5 KB saiu com Content-Encoding: gzip.
O que cada parte resolve
server_tokens off tira a versão do nginx da resposta, o que evita entregar de graça a versão exata a quem fizer um scan. Os add_header de segurança fecham o que o navegador pode fazer com a página: X-Content-Type-Options: nosniff impede que um CSS seja lido como script, X-Frame-Options: SAMEORIGIN impede que a página seja embutida em iframe de outro domínio, Referrer-Policy corta o vazamento de caminho para sites externos, e a Content-Security-Policy mínima bloqueia script e estilo vindos de fora.
O gzip atua só em texto. gzip_min_length 256 evita gastar CPU comprimindo arquivo pequeno, e gzip_vary on garante que um cache no meio do caminho não entregue gzip para quem não pediu. Compressão de imagem ou vídeo aqui não faz sentido, porque já estão comprimidos.
Para cache, a regra é por tipo de arquivo. HTML muda a cada deploy, então recebe Cache-Control: no-cache e revalida sempre com ETag. Assets com hash no nome, como style.a1b2.css, não mudam nunca, então recebem um ano e immutable. O navegador nem pergunta de novo. Quem usa nome sem hash precisa baixar o TTL ou aceitar servir versão velha.
O log_format detalhado grava tempo de resposta ($request_time) ao lado do status. É o campo que mais ajuda quando um site está lento e você não sabe se é rede ou disco. O log vai para um arquivo próprio, o que separa o tráfego do site do resto do host.
O nginx.conf completo
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 256;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format detalhado '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" $request_time';
access_log /var/log/nginx/access.log detalhado;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_proxied any;
gzip_comp_level 5;
gzip_types text/plain text/css text/javascript application/javascript
application/json application/xml image/svg+xml;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
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;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; style-src 'self'; script-src 'self'" always;
location ~* \.(css|js|svg|png|jpg|jpeg|webp|woff2?)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" 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;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; style-src 'self'; script-src 'self'" always;
try_files $uri =404;
}
location / {
expires -1;
add_header Cache-Control "no-cache" 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;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; style-src 'self'; script-src 'self'" always;
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
}
}
Para rodar, salve como nginx.conf numa pasta e suba o contêiner oficial:
mkdir -p site/assets
docker run --rm -p 8080:8080 \
-v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/site:/usr/share/nginx/html:ro" \
nginx:1.31-alpine
A imagem nginxinc/nginx-unprivileged:alpine usa a porta 8080 por padrão e roda sem root, o que combina com as práticas de contêiner sem root. Se preferir a imagem oficial, troque para nginx:1.31-alpine e mude listen 80.
Como verificar
curl -sI http://127.0.0.1:8080/
curl -sI http://127.0.0.1:8080/assets/style.css
curl -s -o /dev/null -w "%{http_code}\n" -H 'If-None-Match: "ETAG_AQUI"' http://127.0.0.1:8080/assets/style.css
curl -sI -H 'Accept-Encoding: gzip' http://127.0.0.1:8080/assets/big.css | grep -i content-encoding
Saída esperada: na primeira, Cache-Control: no-cache e os cabeçalhos de segurança. Na segunda, Cache-Control: public, max-age=31536000, immutable e um ETag. Na terceira, HTTP 304. Na quarta, Content-Encoding: gzip.
Se der errado
nginx: [emerg] bind() to 0.0.0.0:80 failed: a porta já está em uso ou a imagem oficial escuta 80. Troquelisten 8080paralisten 80ou mude o mapeamento-p.403 Forbiddenao pedir um arquivo: orootaponta para uma pasta que o usuário do nginx não lê. Confira permissões comls -lde use a imagem unprivileged, que já tem o dono certo.- Cabeçalhos de segurança não aparecem no HTML: no nginx,
add_headernumlocationcancela a herança doserver. Por isso os headers se repetem em cada bloco. Se adicionar um novolocation, copie a lista ou useadd_header_inherit mergeno nginx 1.29.3 ou mais novo. - Gzip não ativa em arquivo pequeno:
gzip_min_length 256é intencional. Abaixe para 20 se quiser comprimir tudo, mas a economia some e a CPU sobe.
Cache por tipo de arquivo
| Arquivo | TTL | Motivo |
|---|---|---|
index.html |
sempre revalida | muda a cada deploy |
style.a1b2.css, app.9f3e.js |
1 ano, immutable | nome muda quando o conteúdo muda |
| imagens e fontes sem hash | 7 dias | compromisso entre cache e atualização |
sitemap.xml, robots.txt |
1 hora | crawlers relem com frequência |
Quem usa nomes sem hash precisa de TTL curto ou de um passo de build que renomeie. O compose com proxy resolve o mesmo problema quando o site roda atrás de um contêiner.
FAQ
Preciso de HTTPS neste arquivo? Não. TLS termina no proxy ou no balanceador. O nginx aqui só serve o site. Para certificado local em desenvolvimento, veja HTTPS no localhost com mkcert.
Por que não usar try_files $uri $uri/ /index.html para SPA?
Porque esta postagem é sobre site estático. Para SPA, troque a linha por try_files $uri $uri/ /index.html; e mantenha o cache do HTML em no-cache, senão o navegador guarda uma casca velha.
O sendfile on é seguro?
Sim em disco local. Se o root estiver em NFS ou em volume de rede, desligue com sendfile off, porque o kernel pode enviar bytes velhos.
Como saber se o cache está funcionando de verdade?
Rode duas vezes o curl do asset e compare o ETag. Na segunda, com If-None-Match igual, a resposta deve ser 304 sem corpo. O curl na prática mostra como ler esses cabeçalhos.
Posso colocar este arquivo em produção assim?
Sim, mas adicione rate limit (limit_req) e, se for site público, um WAF na frente. O arquivo já não vaza versão e já tem CSP, o que cobre o básico.
Fontes
- Módulo ngx_http_headers_module, nginx.org, versão 1.31.6, consultado em 29 de outubro de 2026.
- Módulo ngx_http_gzip_module, nginx.org, versão 1.31.6, consultado em 29 de outubro de 2026.
- HTTP caching, MDN, consultado em 29 de outubro de 2026.