Contenido
Tutorial de Nginx con Docker: sitio estático y proxy inverso
Esta sección tiene dos partes: primero usarás Nginx para servir un sitio estático y después lo configurarás como proxy inverso delante de otros contenedores de aplicaciones. Al final, verás cómo obtener certificados HTTPS automáticamente con Caddy.
El sitio estático más rápido
Prepara una carpeta nginx-site y coloca los archivos del sitio en 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>
public/app.js tiene una sola línea que muestra en la página la ruta URL actual (la usarás más adelante para probar la SPA):
document.getElementById("path").textContent = "Current path: " + location.pathname;
El contenido de style.css puede ser cualquiera. Monta public/ en el directorio raíz del sitio de la imagen 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 en Windows (selecciona la pestaña para cambiarlo):
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 solo lectura; Nginx solo necesita leer los archivos. Abre http://localhost:8080:

El último comando solicita la ruta inexistente /dashboard/settings y obtiene 404. Esto es correcto para un sitio web normal. Sin embargo, en una aplicación de página única (SPA) como React o Vue, el frontend gestiona las rutas URL, así que el servidor debe devolver index.html. Para ello necesitas un archivo de configuración personalizado.
Configuración personalizada
La imagen oficial carga todos los archivos .conf de /etc/nginx/conf.d/. De forma predeterminada, solo hay un default.conf. La forma más sencilla es sustituirlo por tu propio archivo.
Primera versión de default.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Aplicación de página única (SPA): devuelve index.html si no encuentra el archivo
location / {
try_files $uri $uri/ /index.html;
}
# Almacenamiento en caché prolongado 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;
}
| Configuración | Función |
|---|---|
try_files $uri $uri/ /index.html | Busca primero el archivo y luego el directorio; si no encuentra ninguno, devuelve /index.html |
location ~* \.(js|css|...)$ | ~* indica una expresión regular que no distingue entre mayúsculas y minúsculas; busca recursos estáticos |
gzip on + gzip_types | Comprime las respuestas de tipo texto. HTML se comprime de forma predeterminada; los demás tipos deben incluirse en gzip_types |
Monta también el archivo de configuración:
$ 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
Equivalente para Windows (haz clic en las pestañas para cambiar):
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/settingsahora devuelve200;try_filesfunciona.Content-Encoding: gzipindica que el CSS se comprimió.- Pero
Cache-Controlaparece dos veces:expires 30dgenera automáticamenteCache-Control: max-age=2592000, yadd_headerañade otro encabezado. Los navegadores suelen poder manejarlo, pero no es el resultado que buscamos.
Abre http://localhost:8080/dashboard/settings en el navegador. Se muestra index.html y JavaScript obtiene la ruta /dashboard/settings, así que el enrutamiento del frontend puede mostrar la página correspondiente:

Corrige la configuración de caché: elimina expires y usa un único add_header con el 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
Equivalente para Windows (haz clic en las pestañas para cambiar):
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 son 30 días. immutable indica al navegador que no necesita comprobar si el archivo se ha actualizado durante ese periodo, por lo que solo debes usarlo con archivos cuyo nombre incluya un hash de versión (por ejemplo, app.3f9a1c.js, que las herramientas de compilación del frontend suelen generar automáticamente).
Comprueba la sintaxis y vuelve a cargar la configuración
Después de modificar la configuración, comprueba primero la sintaxis con nginx -t. Si la prueba pasa, vuelve a cargarla con nginx -s reload. No hace falta reiniciar el contenedor y las conexiones activas no se interrumpirán:
$ 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
Los mensajes reconfiguring y start worker process en los registros indican que Nginx ha iniciado nuevos workers con la nueva configuración.
Si hay un error de sintaxis, nginx -t indicará dónde está. Por ejemplo, elimina el punto y coma 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
Equivalente en Windows (haz clic en las pestañas para cambiar):
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
El mensaje de error indica la directiva server_name de la línea 3, pero el problema real es que falta un punto y coma en la línea 2: Nginx interpreta server_name de la línea siguiente como otro parámetro de listen. Si ves un error difícil de entender, recuerda revisar la línea anterior.
Los mensajes de las primeras líneas que empiezan por /docker-entrypoint.sh: los muestra el script de inicio de la imagen oficial. can not modify ... (read-only file system?) aparece porque montamos el archivo de configuración en modo de solo lectura con :ro, así que el script no puede añadir automáticamente la configuración de IPv6. Puedes ignorarlo.
Puedes comprobar el archivo de configuración con solo ejecutar
docker run ... nginx -t; no hace falta iniciar realmente el sitio web. En CI puedes usar este método para comprobar la configuración antes del despliegue.
Genera la configuración con variables de entorno
La imagen oficial admite plantillas: coloca los archivos en /etc/nginx/templates/ con la extensión .conf.template. Al iniciar, sustituye ${variable} por las variables de entorno y guarda el resultado en /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
Equivalente en Windows (haz clic en las pestañas para cambiar):
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} y ${APP_ENV} se sustituyeron por los valores de las variables de entorno, pero las variables propias de Nginx, como $uri, no se modificaron: el script de inicio solo sustituye las variables de entorno definidas. Así, puedes desplegar la misma imagen en entornos de prueba y producción con distintas variables de entorno.
Empaquetar en una imagen
Los montajes son adecuados para el desarrollo; al desplegar, empaqueta la configuración y los archivos del sitio en una imagen. 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
La imagen solo ocupa un poco más que nginx:1.30-alpine (93.7 MB). Para proyectos frontend que primero deben compilarse (React, Vue, etc.), consulta compilaciones multietapa: en la primera etapa, usa Node.js para ejecutar npm run build; en la segunda, copia la carpeta generada a la imagen de Nginx.
Proxy inverso: una entrada, varios backends
Una arquitectura habitual expone solo el puerto de Nginx; los contenedores de las aplicaciones backend no exponen ninguno:
Usuario ──▶ nginx:80 ──┬──▶ app:3000 (/ → Express)
└──▶ api:8000 (/api/ → FastAPI)
Aquí usaremos directamente como backends los proyectos preparados en las dos secciones anteriores: Express de la aplicación Node.js y FastAPI de la aplicación Python.
proxy-demo/
├── compose.yaml
├── nginx.conf
├── app/ ← Proyecto Express de la sección de Node.js
└── api/ ← Proyecto FastAPI de la sección de 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;
}
| Configuración | Función |
|---|---|
proxy_pass http://app:3000 | Reenvía al puerto 3000 del servicio Compose app (el puerto dentro del contenedor) |
Host | Conserva el dominio de la solicitud del usuario; de lo contrario, el backend verá app:3000 |
X-Real-IP, X-Forwarded-For | Transmiten la IP real del usuario; de lo contrario, el backend solo verá la IP del contenedor de Nginx |
X-Forwarded-Proto | Indica al backend si la solicitud original era http o https |
client_max_body_size 20m | Tamaño máximo permitido para las subidas; el valor predeterminado es solo 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
Solo nginx tiene ports. Inicia los servicios:
$ 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
Prueba las distintas rutas:
$ 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>
- En
ps, PORTS deappyapisolo muestra3000/tcpy8000/tcp, sin asignación al host; desde el exterior solo se puede acceder a ellos a través de Nginx. /se reenvía a Express;/api/y/api/items/7se reenvían a FastAPI.- También se puede acceder a
/docsde FastAPI mediante/api/docs. (En un uso real, tendrás que ajustar la ruta/openapi.jsonque se referencia en la página mediante la configuraciónroot_pathde FastAPI.)
Prueba práctica: la barra final de proxy_pass
Como proxy_pass http://api:8000/; termina en /, Nginx sustituye /api/, que coincide con location, por /. Por eso, al reenviar /api/items/7 al backend, la ruta pasa a ser /items/7. Si no se incluye la / final, se reenvía tal cual como /api/items/7; si el backend no tiene esa ruta, devuelve 404. Este es uno de los errores de configuración más frecuentes. Vamos a probarlo.
Elimina la / final y vuelve a cargar la configuración. Aquí también aparece otro problema:
$ 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
Veamos qué ha pasado:
- Modificas
nginx.confen la máquina host consed -iy confirmas el cambio congrep. - Pero
/etc/nginx/conf.d/default.confdentro del contenedor sigue siendo el antiguo. La razón es quesed -i(al igual que vim y muchos otros editores) crea un archivo nuevo al guardar y sustituye el anterior. Un montaje de un solo archivo está vinculado al archivo original, por lo que el contenedor no ve el nuevo. En este caso, ni siquiera sirve ejecutarnginx -s reload. docker compose restart nginxreinicia el contenedor. Solo después de volver a montar el archivo aparece el contenido nuevo.- Sin la barra final,
/api/items/7?q=proxydevuelve{"detail":"Not Found"}. - Los registros de FastAPI confirman que recibió
GET /api/items/7, no/items/7.
Para evitar problemas con los montajes de un solo archivo, monta la carpeta completa, por ejemplo
./nginx:/etc/nginx/conf.d:ro. Así, el contenedor ve los cambios cuando se sustituye un archivo de la carpeta. También puedes reiniciar siempre el contenedor después de modificar la configuración.
502 Bad Gateway
Un 502 indica que Nginx recibió la solicitud, pero no puede conectarse al backend. Vamos a probar varios casos frecuentes.
El puerto del backend está mal escrito (cambia app:3000 por app:3001 y reinicia 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"
El 502 llega de inmediato y el registro de errores muestra Connection refused: se encuentra el host app, pero no hay ningún programa escuchando en el puerto 3001. También se produce el mismo error si el programa del backend solo está vinculado a 127.0.0.1 o todavía se está iniciando.

El contenedor del backend se ha detenido:
$ 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"
El 502 llega unos 3 segundos después y el error es Host is unreachable. Nginx resolvió app a una dirección IP al iniciarse y la guardó. Cuando se detiene el contenedor, esa IP deja de responder. Si la red no informa enseguida de que no se puede conectar, Nginx espera hasta que se agota el tiempo de espera (60 segundos de forma predeterminada) y devuelve 504 Gateway Timeout.
El servicio backend no existe (inicia solo Nginx, sin iniciar app ni 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
Esta vez Nginx ni siquiera logra iniciarse: host not found in upstream "app". Nginx necesita resolver el nombre de host de proxy_pass al iniciarse. Si no puede resolverlo, se niega a arrancar y el contenedor se reinicia una y otra vez debido a restart: unless-stopped (Restarting (1)).
Sigue este orden para diagnosticar un 502:
- Comprueba si el contenedor del backend está en ejecución:
docker compose ps - Comprueba que el nombre del servicio y el puerto de
proxy_passsean correctos (se usa el puerto dentro del contenedor, no el puerto del host asignado enports). - Comprueba que el backend escuche en
0.0.0.0, no en127.0.0.1. - Comprueba que Nginx y el backend estén en la misma red. Consulta Redes y comunicación entre contenedores.
- Consulta los registros de errores de Nginx:
docker compose logs nginx | grep error
HTTPS: obtén certificados automáticamente con Caddy
Para usar certificados de Let's Encrypt en Nginx, hay que configurar certbot, renovarlos periódicamente y volver a cargar la configuración. Si solo quieres añadir HTTPS rápidamente, Caddy solicita, instala y renueva los certificados automáticamente, y su configuración es mucho más breve.
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 un dominio real, se deben cumplir estas condiciones:
- El DNS del dominio ya apunta a esta máquina.
- Se puede acceder a los puertos 80 y 443 desde Internet (Let's Encrypt los necesita para verificar el dominio).
caddy_dataalmacena los certificados. No lo elimines; de lo contrario, tendrás que volver a solicitarlos cada vez que reconstruyas el servicio y podrías alcanzar el límite de solicitudes de Let's Encrypt.
Probarlo localmente
Si no tienes un dominio real, puedes escribir localhost como dominio en Caddyfile. Caddy crea su propia autoridad de certificación local y emite un 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/llega correctamente a Express. Como el certificado no lo ha emitido una autoridad de certificación pública,curlnecesita la opción-kpara omitir la verificación, y el navegador también mostrará una advertencia.- Las solicitudes a
http://reciben automáticamente una respuesta308 Permanent Redirectque las redirige ahttps://; no hace falta configurarlo por separado. - Los registros muestran que Caddy activó automáticamente la redirección de HTTP a HTTPS y la gestión de certificados, y que obtuvo un certificado.
- El emisor del certificado es
Caddy Local Authority. Al usar un dominio real, solicitará el certificado a Let's Encrypt.
reverse_proxy de Caddy reenvía de forma predeterminada encabezados como X-Forwarded-For y X-Forwarded-Proto; no hace falta configurarlos uno por uno como en Nginx.
Resumen
- Las imágenes oficiales cargan
/etc/nginx/conf.d/*.confy también admiten la sustitución de variables de entorno entemplates/*.conf.template. - Para una SPA, usa
try_files $uri $uri/ /index.html; no usesexpiresyadd_header Cache-Controlal mismo tiempo. - Si
proxy_passtermina en/, elimina el prefijo delocation. - Expón puertos al exterior solo en el proxy inverso.
Ejercicios
Ejercicio 1: En el ejemplo de proxy inverso, cambia la ruta de FastAPI de /api/ a /backend/. ¿Qué debes modificar?
Respuesta
Solo tienes que cambiar location /api/ por location /backend/ en nginx.conf, mantener la / final en proxy_pass http://api:8000/; y luego ejecutar docker compose exec nginx nginx -s reload (si montas un solo archivo, usa docker compose restart nginx).
Ejercicio 2: ¿Cómo puedes comprobar con un solo comando si un archivo de configuración de nginx tiene errores de sintaxis, sin iniciar el sitio web?
Respuesta
docker run --rm -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
Preguntas frecuentes
¿Hay que reiniciar el contenedor después de modificar nginx.conf?
Primero comprueba la configuración con docker exec contenedor nginx -t y luego ejecuta nginx -s reload; no hace falta reiniciarlo. Sin embargo, al montar un solo archivo, al guardar desde el editor puede que el contenedor no vea el archivo nuevo. Consulta las pruebas de este capítulo.
¿Qué significa 502 Bad Gateway?
Nginx no puede conectarse al backend. Comprueba que el contenedor del backend esté en ejecución, que el nombre del servicio y el puerto interno del contenedor en proxy_pass sean correctos, y que el backend escuche en 0.0.0.0.
¿Cuál debería elegir, Nginx o Caddy?
Usa Nginx si necesitas un control detallado o tu equipo ya lo conoce. Si solo quieres añadir HTTPS rápidamente, Caddy requiere mucha menos configuración y renueva los certificados automáticamente.
Actualizado 14 de octubre de 2026