>_ josetilabs.com
← blog

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.

Terminal mostrando a resposta HTTP antes e depois da configuracao: antes apenas Server e Date; depois Cache-Control, X-Frame-Options e Content-Security-Policy
A diferença entre a resposta padrão e a resposta com o arquivo desta postagem.

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

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

text
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

text
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

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

Leia também

// Por e-mail

Os textos publicados aqui também vão por e-mail. O envio ainda não começou: quem deixar o endereço agora entra na primeira lista.

Guardo só o seu e-mail, a data e o registro desta autorização. Sem cookies, sem rastreador e nada compartilhado com terceiros. Detalhes na política de privacidade.