Conteúdo

Tutorial de Nginx com Docker: site estático e proxy reverso

Esta seção tem duas partes: primeiro, use o Nginx para servir um site estático; depois, use-o como proxy reverso diante dos contêineres de outros aplicativos. Por fim, veja como usar o Caddy para obter certificados HTTPS automaticamente.

Site estático da forma mais rápida

Prepare uma pasta nginx-site e coloque os arquivos do site em public/:

nginx-site/
└── public/
    ├── index.html
    ├── style.css
    └── app.js

public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>My Static Site</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <main>
    <h1>My Static Site</h1>
    <p>Served by Nginx in a Docker container.</p>
    <p id="path"></p>
  </main>
  <script src="/app.js"></script>
</body>
</html>

O arquivo public/app.js tem apenas uma linha, que mostra o caminho atual da URL na página (isso será usado mais adiante no teste de SPA):

document.getElementById("path").textContent = "Current path: " + location.pathname;

O conteúdo de style.css pode ser qualquer um. Monte public/ no diretório raiz dos sites da imagem oficial, /usr/share/nginx/html:

$ docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
806fb20a7c3ba980710bf43520c118d724346248d387f8743f8ba26e5e182345

$ curl -s http://localhost:8080/ | head -n 6
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>My Static Site</title>

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
404

Comando equivalente no Windows (selecione uma aba para alternar):

docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine

:ro significa somente leitura; o Nginx só precisa ler os arquivos. Abra http://localhost:8080:

Site estático servido por um contêiner Nginx
Página estática servida pelo Nginx, com CSS e JavaScript carregados corretamente

O último comando solicita um caminho inexistente, /dashboard/settings, e recebe 404. Isso é correto para um site comum. Porém, em um aplicativo de página única (SPA), como React ou Vue, o caminho da URL é tratado pelo front-end, então o servidor deve retornar index.html. Para isso, é necessário um arquivo de configuração personalizado.

Arquivo de configuração personalizado

A imagem oficial carrega todos os arquivos .conf em /etc/nginx/conf.d/; por padrão, há apenas um default.conf. A maneira mais simples é substituí-lo pelo seu próprio arquivo.

Primeira versão de default.conf:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Aplicação de página única (SPA): retorna index.html quando o arquivo não é encontrado
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache de longa duração para recursos estáticos
    location ~* \.(js|css|png|jpg|svg|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
}
ConfiguraçãoFunção
try_files $uri $uri/ /index.htmlProcura primeiro o arquivo e depois a pasta. Se não encontrar nenhum, retorna /index.html
location ~* \.(js|css|...)$~* indica uma expressão regular sem distinção entre maiúsculas e minúsculas, que corresponde a recursos estáticos
gzip on + gzip_typesComprime respostas de texto. O HTML já é comprimido por padrão; os outros tipos precisam ser listados em gzip_types

Monte também o arquivo de configuração:

$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
89c2d3163e3db3bb4192bac6dfd48732c5f72d2d9d4584b9d3e1a32416c01f2a

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
200

$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css
HTTP/1.1 200 OK
Server: nginx/1.30.5
Date: Tue, 13 Oct 2026 00:40:31 GMT
Content-Type: text/css
Last-Modified: Tue, 13 Oct 2026 00:38:42 GMT
Connection: keep-alive
ETag: W/"6acd7d92-154"
Expires: Thu, 12 Nov 2026 00:40:31 GMT
Cache-Control: max-age=2592000
Cache-Control: public, immutable
Content-Encoding: gzip

Forma equivalente no Windows (clique nas abas para alternar):

docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
  • /dashboard/settings agora retorna 200; try_files está funcionando.
  • Content-Encoding: gzip indica que o CSS foi comprimido.
  • Porém, Cache-Control aparece duas vezes: expires 30d gera automaticamente Cache-Control: max-age=2592000, e add_header adiciona outro. Em geral, o navegador consegue lidar com isso, mas não é o resultado desejado.

Abra http://localhost:8080/dashboard/settings no navegador. A página exibida é index.html, e o JavaScript lê o caminho /dashboard/settings; assim, o roteador do front-end pode exibir a página correspondente:

O caminho da SPA é tratado por index.html
Mesmo um caminho inexistente retorna index.html, e o front-end lê o caminho /dashboard/settings

Corrija a configuração do cache: remova expires e use apenas um add_header com o valor completo:

    location ~* \.(js|css|png|jpg|svg|woff2)$ {
        add_header Cache-Control "public, max-age=2592000, immutable";
    }
$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
868d331a579d208e646ee1be3a3d2a906720578a39418db2c4bd50febb892fd7

$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css | grep -E "Cache-Control|Content-Encoding"
Cache-Control: public, max-age=2592000, immutable
Content-Encoding: gzip

Forma equivalente no Windows (clique nas abas para alternar):

docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine

2592000 segundos são 30 dias. immutable informa ao navegador que não precisa verificar se o arquivo foi atualizado durante esse período; por isso, use essa opção apenas em arquivos cujo nome contém um hash de versão (por exemplo, app.3f9a1c.js, que as ferramentas de build do front-end geralmente geram automaticamente).

Verifique a sintaxe e recarregue

Depois de alterar a configuração, primeiro verifique a sintaxe com nginx -t. Se estiver tudo certo, use nginx -s reload para recarregar. Não é necessário reiniciar o contêiner, e as conexões em andamento não serão interrompidas:

$ docker exec site nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

$ docker exec site nginx -s reload
2026/10/13 00:40:34 [notice] 27#27: signal process started

$ docker logs site 2>&1 | tail -n 4
2026/10/13 00:40:34 [notice] 1#1: reconfiguring
2026/10/13 00:40:34 [notice] 1#1: using the "epoll" event method
2026/10/13 00:40:34 [notice] 1#1: start worker processes
2026/10/13 00:40:34 [notice] 1#1: start worker process 32

reconfiguring e start worker process nos logs indicam que o Nginx iniciou novos workers com a nova configuração.

Quando há um erro de sintaxe, nginx -t indica onde está o problema. Por exemplo, remova o ponto e vírgula de listen 80;:

$ sed 's/listen 80;/listen 80/' default.conf > broken.conf

$ docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/10/13 00:40:35 [emerg] 1#1: invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: [emerg] invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: configuration file /etc/nginx/nginx.conf test failed

Comando equivalente no Windows (clique nas abas para alternar):

docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "${PWD}/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "%cd%/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t

A mensagem de erro aponta para server_name na linha 3, mas o problema real é a falta do ponto e vírgula na linha 2: o Nginx também interpreta server_name da linha seguinte como um parâmetro de listen. Quando encontrar um erro difícil de entender, lembre-se de verificar a linha anterior.

As mensagens das primeiras linhas que começam com /docker-entrypoint.sh: vêm do script de inicialização da imagem oficial. A mensagem can not modify ... (read-only file system?) aparece porque montamos o arquivo de configuração como somente leitura com :ro, e o script não consegue adicionar a configuração de IPv6 automaticamente. Você pode ignorá-la.

Basta usar docker run ... nginx -t para verificar o arquivo de configuração; não é preciso iniciar o site de verdade. Em CI, você pode usar esse método para verificar a configuração antes de fazer o deploy.

Gere a configuração com variáveis de ambiente

A imagem oficial oferece suporte a modelos: coloque os arquivos em /etc/nginx/templates/ com a extensão .conf.template. Na inicialização, os valores de ${variável} serão substituídos pelas variáveis de ambiente, e o resultado será gravado em /etc/nginx/conf.d/.

templates/default.conf.template:

server {
    listen 80;
    server_name ${SERVER_NAME};
    root /usr/share/nginx/html;

    location / {
        add_header X-Environment ${APP_ENV};
        try_files $uri $uri/ /index.html;
    }
}
$ docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
77b6991d1d85b713cc741c86504d61e6a8dc867997e80219ec77b3f3c3be3165

$ docker logs tpl 2>&1 | grep -i template
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
20-envsubst-on-templates.sh: Running envsubst on /etc/nginx/templates/default.conf.template to /etc/nginx/conf.d/default.conf

$ docker exec tpl cat /etc/nginx/conf.d/default.conf
server {
    listen 80;
    server_name example.com;
    root /usr/share/nginx/html;

    location / {
        add_header X-Environment staging;
        try_files $uri $uri/ /index.html;
    }
}

$ curl -sI http://localhost:8080/ | grep X-Environment
X-Environment: staging

Comando equivalente no Windows (clique nas abas para alternar):

docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "${PWD}/templates:/etc/nginx/templates:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "%cd%/templates:/etc/nginx/templates:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine

${SERVER_NAME} e ${APP_ENV} foram substituídas pelos valores das variáveis de ambiente, mas variáveis do próprio Nginx, como $uri, permaneceram intactas: o script de inicialização substitui apenas as variáveis de ambiente já definidas. Assim, você pode implantar a mesma imagem em ambientes de teste e produção usando diferentes variáveis de ambiente.

Empacote em uma imagem

Montar arquivos é adequado para o desenvolvimento; ao fazer o deploy, empacote as configurações e os arquivos do site juntos em uma imagem. Dockerfile:

FROM nginx:1.30-alpine
COPY default.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
$ docker build -t my-site .

[+] Building 2.3s (8/8) FINISHED                                                                                                       docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 145B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/nginx:1.30-alpine                                                                             0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 2B                                                                                                                  0.0s
 => [1/3] FROM docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94                       1.9s
 => => resolve docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94                       0.1s
 => => extracting sha256:e3320d02d5786716dcc40d9f6267baec5d3d2c46945082958050c3ac5ea23751                                                        0.3s
 => => extracting sha256:3d85d110b167fae23d8eb3a7060cd079682b682b04d295fc7bbdf454c9e722c8                                                        0.0s
 => => extracting sha256:22e5a8a110ec69c6af30325ccfe8bb0bfb94e5783117808061bae5115e1e3dcb                                                        0.0s
 => => extracting sha256:6b4dfb2e8f8a72d7ae1380b667f50faccdc19147869b2afa09aeddfdea59ce64                                                        0.0s
 => => extracting sha256:703c5424632fab32aa3327bebe6e5202ac53a93740062efb830f8188e580b9db                                                        0.0s
 => => extracting sha256:b335b7ac3a400afa780d0d89e0b778ee19dc3e3df95bed4b274a763af2264fd7                                                        0.0s
 => => extracting sha256:b27cf3f7c39dd0da0aa52a285f0c0cce938ac61472babfea353b1daa83586c49                                                        1.4s
 => [internal] load build context                                                                                                                0.1s
 => => transferring context: 1.51kB                                                                                                              0.0s
 => [2/3] COPY default.conf /etc/nginx/conf.d/default.conf                                                                                       0.1s
 => [3/3] COPY public/ /usr/share/nginx/html/                                                                                                    0.0s
 => exporting to image                                                                                                                           0.1s
 => => exporting layers                                                                                                                          0.0s
 => => exporting manifest sha256:8bac0b1cbdfd6612d9eb656089bb18a18c4dba64cb8557aec5d427164a205865                                                0.0s
 => => exporting config sha256:5b6710aa0888ae70f084ad7911539cb362f4f919de995cf997898d37a9968baf                                                  0.0s
 => => exporting attestation manifest sha256:9be9e0e07eacaece4ad72b53c87a2a7f84b4fa99af726bf98a33229fbbf9e4eb                                    0.0s
 => => exporting manifest list sha256:af4d78aa254eab0a55dd440f1e220ed73d0a5141343d9ae0fe19890e8471cd04                                           0.0s
 => => naming to docker.io/library/my-site:latest                                                                                                0.0s
 => => unpacking to docker.io/library/my-site:latest                                                                                             0.0s

$ docker run -d --name site -p 8080:80 my-site
d614148de5a6c2243074f865a0c03266a8e81ba069733546412d4abe9ad64fa0

$ curl -s http://localhost:8080/ | grep "<h1>"
    <h1>My Static Site</h1>

$ docker images my-site
IMAGE            ID             DISK USAGE   CONTENT SIZE   EXTRA
my-site:latest   af4d78aa254e       92.8MB         26.1MB   U

A imagem fica só um pouco maior que nginx:1.30-alpine (93.7 MB). Para projetos de front-end que precisam ser compilados primeiro (React, Vue etc.), consulte builds em vários estágios: na primeira etapa, use Node.js para executar npm run build; na segunda, copie a pasta gerada para a imagem do Nginx.

Proxy reverso: uma entrada, vários backends

Uma arquitetura comum expõe apenas a porta do Nginx; os contêineres das aplicações de backend não expõem portas:

Usuário ──▶ nginx:80 ──┬──▶ app:3000   (/      → Express)
                      └──▶ api:8000   (/api/  → FastAPI)

Aqui, usamos diretamente como backends os projetos preparados nas duas seções anteriores: o Express da aplicação Node.js e o FastAPI da aplicação Python.

proxy-demo/
├── compose.yaml
├── nginx.conf
├── app/      ← Projeto Express da seção sobre Node.js
└── api/      ← Projeto FastAPI da seção sobre Python

nginx.conf:

server {
    listen 80;

    location / {
        proxy_pass http://app: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;
    }

    location /api/ {
        proxy_pass http://api:8000/;
        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;
    }

    client_max_body_size 20m;
}
ConfiguraçãoFunção
proxy_pass http://app:3000Encaminha para a porta 3000 do serviço Compose app (a porta dentro do contêiner)
HostPreserva o domínio da solicitação do usuário; caso contrário, o backend verá app:3000
X-Real-IP, X-Forwarded-ForEncaminha o IP real do usuário; caso contrário, o backend verá apenas o IP do contêiner do Nginx
X-Forwarded-ProtoInforma ao backend se a solicitação original usou http ou https
client_max_body_size 20mTamanho máximo permitido para uploads; o padrão é apenas 1 MB

compose.yaml:

services:
  nginx:
    image: nginx:1.30-alpine
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
      - api
    restart: unless-stopped

  app:
    build: ./app
    restart: unless-stopped

  api:
    build: ./api
    restart: unless-stopped

Somente nginx tem ports. Inicie:

$ docker compose up -d --build

[+] Building 2.6s (23/23) FINISHED
 => [internal] load local bake definitions                                                                                                       0.0s
 => => reading from stdin 885B                                                                                                                   0.0s
 => [app internal] load build definition from Dockerfile                                                                                         0.0s
 => => transferring dockerfile: 263B                                                                                                             0.0s
 => [api internal] load build definition from Dockerfile                                                                                         0.0s
 => => transferring dockerfile: 290B                                                                                                             0.0s
 => [api internal] load metadata for docker.io/library/python:3.14-slim                                                                          0.0s
 => [app internal] load metadata for docker.io/library/node:24-alpine                                                                            0.0s
 => [app internal] load .dockerignore                                                                                                            0.0s
 => => transferring context: 82B                                                                                                                 0.0s
 => [api internal] load .dockerignore                                                                                                            0.0s
 => => transferring context: 2B                                                                                                                  0.0s
 => [app 1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                      0.1s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.1s
 => [app internal] load build context                                                                                                            0.1s
 => => transferring context: 30.70kB                                                                                                             0.0s
 => [api 1/5] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                    0.1s
 => => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.1s
 => [api internal] load build context                                                                                                            0.1s
 => => transferring context: 655B                                                                                                                0.0s
 => CACHED [app 2/5] WORKDIR /app                                                                                                                0.0s
 => CACHED [app 3/5] COPY package.json package-lock.json ./                                                                                      0.0s
 => CACHED [app 4/5] RUN npm ci --omit=dev && npm cache clean --force                                                                            0.0s
 => [app 5/5] COPY --chown=node:node . .                                                                                                         0.0s
 => CACHED [api 2/5] WORKDIR /app                                                                                                                0.0s
 => CACHED [api 3/5] COPY requirements.txt .                                                                                                     0.0s
 => CACHED [api 4/5] RUN pip install -r requirements.txt                                                                                         0.0s
 => CACHED [api 5/5] COPY . .                                                                                                                    0.0s
 => [app] exporting to image                                                                                                                     0.6s
 => => exporting layers                                                                                                                          0.1s
 => => exporting manifest sha256:30fc2b30e552f64db78c5b43bafd2ada5445c202a13517f1413cfbbf94b39fd3                                                0.0s
 => => exporting config sha256:ec00425160f63ab1fc89cf3ffb5cd5c91034dfe42b069922b6db604c6512dc94                                                  0.0s
 => => exporting attestation manifest sha256:bcd908d6b45f083d6373facdb8103d3cad7f5c8855744d68614f3c983fe2a3be                                    0.0s
 => => exporting manifest list sha256:ec1d3f8403286973e156b78f0db4566b8815c8e885f10c176b64c2c6cb2a75c5                                           0.0s
 => => naming to docker.io/library/proxy-demo-app:latest                                                                                         0.0s
 => => unpacking to docker.io/library/proxy-demo-app:latest                                                                                      0.4s
 => [api] exporting to image                                                                                                                     2.1s
 => => exporting layers                                                                                                                          0.0s
 => => exporting manifest sha256:89889ca77d8a1fe2e05952bd565d6f4b777e87d2343df4d846ad16bec3752caa                                                0.0s
 => => exporting config sha256:6bc4e896d1388da575d77436bebb27a4b9941ed84896155384aef74eb4c6bf7c                                                  0.0s
 => => exporting attestation manifest sha256:c4130d12dd13346aa1cc105159cb8ff35177f368269cca237584c9e648fd2da7                                    0.0s
 => => exporting manifest list sha256:104bdf1ed74105f0bd21955d695d87d5225e50e4df7ab5715ac86eb551aac340                                           0.0s
 => => naming to docker.io/library/proxy-demo-api:latest                                                                                         0.0s
 => => unpacking to docker.io/library/proxy-demo-api:latest                                                                                      2.1s
 => [app] resolving provenance for metadata file                                                                                                 0.0s
 => [api] resolving provenance for metadata file                                                                                                 0.0s
[+] up 6/6
 ✔ Image proxy-demo-api         Built                                                                                                             2.7s
 ✔ Image proxy-demo-app         Built                                                                                                             2.7s
 ✔ Network proxy-demo_default   Created                                                                                                           0.0s
 ✔ Container proxy-demo-app-1   Started                                                                                                           0.4s
 ✔ Container proxy-demo-api-1   Started                                                                                                           0.7s
 ✔ Container proxy-demo-nginx-1 Started                                                                                                           1.2s

Teste os diferentes caminhos:

$ docker compose ps
NAME                 IMAGE               COMMAND                  SERVICE   CREATED         STATUS         PORTS
proxy-demo-api-1     proxy-demo-api      "uvicorn main:app --…"   api       7 seconds ago   Up 6 seconds   8000/tcp
proxy-demo-app-1     proxy-demo-app      "docker-entrypoint.s…"   app       7 seconds ago   Up 6 seconds   3000/tcp
proxy-demo-nginx-1   nginx:1.30-alpine   "/docker-entrypoint.…"   nginx     7 seconds ago   Up 5 seconds   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp

$ curl -s http://localhost:8080/
{"message":"Hello from Express","node":"v24.21.0"}

$ curl -s http://localhost:8080/api/
{"message":"Hello from FastAPI"}

$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"item_id":7,"q":"proxy"}

$ curl -s http://localhost:8080/api/docs | grep -o "<title>.*</title>"
<title>Todo API - Swagger UI</title>
  • Em ps, PORTS de app e api mostram apenas 3000/tcp e 8000/tcp, sem mapeamento para o host; o acesso externo só é possível por meio do Nginx.
  • / é encaminhado para o Express; /api/ e /api/items/7 são encaminhados para o FastAPI.
  • Também é possível acessar /docs do FastAPI por meio de /api/docs. (No uso real, o caminho /openapi.json referenciado pela página precisa ser ajustado com a configuração root_path do FastAPI.)

Teste prático: a barra no final de proxy_pass

Quando proxy_pass http://api:8000/; termina com /, o Nginx substitui /api/, correspondido por location, por /. Assim, /api/items/7 é encaminhado ao backend como /items/7. Sem a / final, o caminho é encaminhado como /api/items/7; se o backend não tiver esse caminho, retorna 404. Esse é um dos erros de configuração mais comuns. Vamos testar na prática.

Remova a / final e recarregue a configuração. Aqui também aparece outra armadilha:

$ sed -i 's#http://api:8000/;#http://api:8000;#' nginx.conf

$ grep "api:8000" nginx.conf
        proxy_pass http://api:8000;

$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
        proxy_pass http://api:8000/;

$ docker compose restart nginx
 Container proxy-demo-nginx-1 Restarting
 Container proxy-demo-nginx-1 Started

$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
        proxy_pass http://api:8000;

$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"detail":"Not Found"}

$ docker compose logs --tail 1 api
api-1  | INFO:     172.19.0.4:44428 - "GET /api/items/7?q=proxy HTTP/1.1" 404 Not Found

Veja o que aconteceu, em ordem:

  1. Use sed -i para editar nginx.conf no host e grep para confirmar a alteração.
  2. Mas /etc/nginx/conf.d/default.conf dentro do contêiner ainda está desatualizado. Isso acontece porque sed -i (assim como muitos editores, como o vim) cria um arquivo novo ao salvar e o coloca no lugar do antigo. Uma montagem de arquivo individual fica vinculada ao arquivo original, então o contêiner não vê o arquivo novo. Nesse caso, nem executar nginx -s reload adianta.
  3. docker compose restart nginx reinicia o contêiner e, após montar o arquivo novamente, ele passa a ver o conteúdo atualizado.
  4. Sem a barra final, /api/items/7?q=proxy retorna {"detail":"Not Found"}.
  5. O log do FastAPI confirma que ele recebeu GET /api/items/7, e não /items/7.

Para evitar problemas com uma montagem de arquivo individual, monte a pasta inteira, por exemplo, ./nginx:/etc/nginx/conf.d:ro. Assim, o contêiner também vê quando os arquivos da pasta são substituídos. Outra opção é sempre reiniciar o contêiner depois de alterar a configuração.

502 Bad Gateway

O erro 502 indica que o Nginx recebeu a solicitação, mas não conseguiu se conectar ao backend. Vamos testar algumas situações comuns.

A porta do backend está incorreta (troque app:3000 por app:3001 e reinicie o Nginx):

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 0.001010s

$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1  | 2026/10/13 00:43:32 [error] 21#21: *1 connect() failed (111: Connection refused) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3001/", host: "localhost:8080"

O erro 502 retorna imediatamente, e o log de erros mostra Connection refused: o host app foi encontrado, mas não há nenhum programa escutando na porta 3001. O mesmo erro ocorre quando o programa do backend está vinculado apenas a 127.0.0.1 ou ainda está iniciando.

Página de erro 502 Bad Gateway do Nginx
Página de erro 502 Bad Gateway exibida no navegador

O contêiner do backend parou:

$ docker compose stop app
 Container proxy-demo-app-1 Stopping
 Container proxy-demo-app-1 Stopped

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 3.062307s

$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1  | 2026/10/13 00:43:39 [error] 20#20: *1 connect() failed (113: Host is unreachable) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3000/", host: "localhost:8080"

O erro 502 retorna após cerca de 3 segundos, com a mensagem Host is unreachable. Ao iniciar, o Nginx resolveu app para um IP e guardou esse endereço. Depois que o contêiner parou, esse IP deixou de responder. Se a rede não informar imediatamente que a conexão é impossível, o Nginx esperará até o tempo limite (60 segundos por padrão) e retornará 504 Gateway Timeout.

O serviço de backend nem existe (inicie apenas o Nginx, sem iniciar app nem api):

$ docker compose up -d --no-deps nginx
 Container proxy-demo-nginx-1 Started

$ docker compose ps -a nginx
NAME                 IMAGE               COMMAND                  SERVICE   CREATED         STATUS                                  PORTS
proxy-demo-nginx-1   nginx:1.30-alpine   "/docker-entrypoint.…"   nginx     3 seconds ago   Restarting (1) Less than a second ago

$ docker compose logs nginx 2>&1 | grep emerg | head -n 2
nginx-1  | 2026/10/13 00:43:48 [emerg] 1#1: host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5
nginx-1  | nginx: [emerg] host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5

Desta vez, o Nginx nem consegue iniciar: host not found in upstream "app". Na inicialização, o Nginx precisa resolver o nome do host em proxy_pass. Se não conseguir, ele se recusa a iniciar e o contêiner fica reiniciando por causa de restart: unless-stopped (Restarting (1)).

Siga esta ordem para investigar um erro 502:

  1. Verifique se o contêiner do backend está em execução: docker compose ps
  2. Confira se o nome do serviço e a porta em proxy_pass estão corretos (use a porta dentro do contêiner, não a porta do host mapeada em ports).
  3. Verifique se o backend está escutando em 0.0.0.0, e não em 127.0.0.1.
  4. Confira se o Nginx e o backend estão na mesma rede. Consulte Comunicação entre redes e contêineres.
  5. Consulte o log de erros do Nginx: docker compose logs nginx | grep error

HTTPS: obtenha certificados automaticamente com Caddy

Para usar certificados do Let's Encrypt no Nginx, você precisa configurar o certbot, fazer atualizações periódicas e recarregar o Nginx. Se quiser adicionar HTTPS rapidamente, o Caddy solicita, instala e atualiza os certificados automaticamente, e sua configuração é bem mais curta.

Caddyfile:

example.com {
    reverse_proxy app:3000
}

compose.yaml:

services:
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped

  app:
    build: ./app
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:

Para usar um domínio real:

  • O DNS do domínio já deve apontar para este host.
  • As portas 80 e 443 devem estar acessíveis pela internet (o Let's Encrypt precisa delas para fazer a validação).
  • caddy_data armazena os certificados. Não o exclua, ou você terá de solicitar os certificados novamente a cada recriação, o que pode acionar o limite de frequência do Let's Encrypt.

Teste local

Quando não houver um domínio real disponível localmente, você pode escrever localhost como domínio no Caddyfile. O Caddy cria uma autoridade de certificação local e emite um certificado:

localhost {
    reverse_proxy app:3000
}
$ docker compose up -d
[+] up 5/5
 ✔ Network caddy-demo_default     Created                                                                                                         0.0s
 ✔ Volume caddy-demo_caddy_config Created                                                                                                         0.0s
 ✔ Volume caddy-demo_caddy_data   Created                                                                                                         0.0s
 ✔ Container caddy-demo-caddy-1   Started                                                                                                         0.3s
 ✔ Container caddy-demo-app-1     Started                                                                                                         0.6s

$ curl -sk https://localhost/
{"message":"Hello from Express","node":"v24.21.0"}

$ curl -sI http://localhost/ | head -n 3
HTTP/1.1 308 Permanent Redirect
Connection: close
Location: https://localhost/

$ docker compose logs caddy 2>&1 | grep -oE '"msg":"(enabling automatic[^"]*|obtaining certificate|certificate obtained successfully)"'
"msg":"enabling automatic HTTP->HTTPS redirects"
"msg":"enabling automatic TLS certificate management"
"msg":"obtaining certificate"
"msg":"certificate obtained successfully"

$ curl -svk https://localhost/ 2>&1 | grep -E "issuer|subject:"
*   subject:
*   issuer: CN=Caddy Local Authority - ECC Intermediate
  • https://localhost/ chega ao Express com sucesso. Como o certificado não foi emitido por uma autoridade de certificação pública, curl precisa da opção -k para ignorar a verificação, e o navegador também exibirá um aviso.
  • As solicitações para http:// recebem automaticamente um 308 Permanent Redirect para https://, sem precisar de configuração adicional.
  • Os logs mostram que o Caddy ativou automaticamente o redirecionamento de HTTP para HTTPS e o gerenciamento de certificados, além de obter o certificado.
  • O emissor do certificado é Caddy Local Authority. Ao usar um domínio real, o Caddy solicitará o certificado à Let's Encrypt.

Por padrão, o reverse_proxy do Caddy encaminha cabeçalhos como X-Forwarded-For e X-Forwarded-Proto. Você não precisa configurá-los um por um como no Nginx.

Resumo

  • A imagem oficial carrega /etc/nginx/conf.d/*.conf e também oferece substituição de variáveis de ambiente em templates/*.conf.template.
  • Para uma SPA, use try_files $uri $uri/ /index.html; não use expires e add_header Cache-Control ao mesmo tempo.
  • Se proxy_pass terminar com /, o prefixo de location será removido.
  • Exponha para fora apenas a porta do proxy reverso.

Exercícios

Exercício 1: No exemplo de proxy reverso, altere o caminho do FastAPI de /api/ para /backend/. O que você precisa modificar?

Resposta

Basta alterar location /api/ para location /backend/ em nginx.conf, manter a / no final de proxy_pass http://api:8000/; e executar docker compose exec nginx nginx -s reload (se estiver usando uma montagem de arquivo único, use docker compose restart nginx).

Exercício 2: Como verificar se um arquivo de configuração do nginx tem erros de sintaxe com um único comando, sem iniciar o site?

Resposta
docker run --rm -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t

Perguntas frequentes

Preciso reiniciar o contêiner depois de alterar nginx.conf?

Primeiro, execute docker exec contêiner nginx -t para verificar e, em seguida, nginx -s reload. Não é preciso reiniciar. Porém, ao montar um único arquivo, salvar no editor pode fazer com que o contêiner não veja o novo arquivo. Veja os testes práticos neste capítulo.

O que significa 502 Bad Gateway?

O Nginx não consegue se conectar ao back-end. Verifique se o contêiner de back-end está em execução, o nome do serviço e a porta interna do contêiner em proxy_pass e se o back-end está escutando em 0.0.0.0.

Devo escolher Nginx ou Caddy?

Use Nginx quando precisar de controle detalhado ou sua equipe já estiver familiarizada com ele. Se quiser apenas adicionar HTTPS rapidamente, a configuração do Caddy é bem mais curta e ele atualiza os certificados automaticamente.

Atualizado 14 de outubro de 2026