Nginx como reverse proxy para contenedores Docker: guía práctica
Configura Nginx como reverse proxy enrutando varios contenedores Docker por dominio, con TLS, headers correctos y red aislada.
Cuando tienes más de un contenedor HTTP corriendo en la misma VPS, publicar puertos en el host se vuelve un caos rápidamente: cada servicio en un puerto diferente, firewall con 8 agujeros, certificado TLS replicado, logs dispersos. La solución estándar de la industria es poner un Nginx delante haciendo reverse proxy — un único puerto público (443), varios backends enrutados por hostname.
Este tutorial muestra cómo ejecutar Nginx en contenedor, conectado a los backends vía una red Docker dedicada, sin exponer ningún puerto de los backends al host. Vamos a cubrir routing por dominio, headers de proxy correctos, TLS automático con Let’s Encrypt y reload sin downtime.
El objetivo es un sysadmin con 2-3 contenedores ya corriendo (una API,
un admin, una app pública) que quiere servir todo en api.ejemplo.com,
admin.ejemplo.com y ejemplo.com desde la misma IP. Tiempo estimado:
30-40 minutos para la config inicial, contando la emisión del
certificado.
Prerrequisitos
VPS con Ubuntu 22.04+ o Debian 12+, Docker Engine 24+ y Docker Compose
v2 instalados. Acceso root o usuario en el grupo docker. DNS de los
dominios ya apuntando a la IP de la VPS (registros A con TTL bajo
durante el setup). Puertos 80 y 443 abiertos en el firewall y ningún
otro proceso escuchando en ellos.
80 443 proxy-net /opt/nginx-proxy Antes de continuar, valida que nada esté usando los puertos:
sudo ss -tlnp | grep -E ':80|:443'
Si aparece Apache, otro Nginx del host o algún contenedor ya bindeado, deténlo antes de seguir — dos cosas escuchando 443 no conviven.
Arquitectura de la solución
El diseño es: una red Docker user-defined llamada proxy-net donde
viven Nginx y todos los backends. Los backends no publican ningún
puerto en el host (sin -p en el compose). Nginx es el único
contenedor con ports: 80:80 y 443:443. Dentro de la red, resuelve
cada backend por el nombre del contenedor vía DNS interno de Docker.
Ventajas: el atacante en internet pública solo ve 80/443. Los backends quedan aislados — incluso si la aplicación tiene un endpoint admin sin auth, no está expuesto. Y agregar un nuevo servicio es un cambio en el compose más un server block en Nginx, sin tocar el firewall.
Creando la red dedicada
Los contenedores en redes diferentes no se ven por nombre (la default bridge es la excepción, pero el DNS allí solo funciona por IP, no por nombre). Todo nuestro stack debe estar en la misma red user-defined.
Crea la red proxy-net:
docker network create proxy-netEsto aparece en docker network ls con driver bridge. Puedes
verificar con docker network inspect proxy-net — estará vacía por
ahora.
Crea el directorio de trabajo del reverse proxy:
sudo mkdir -p /opt/nginx-proxy/{conf.d,certs,vhost,html}
sudo chown -R $USER:$USER /opt/nginx-proxy
cd /opt/nginx-proxyconf.d guarda los server blocks por dominio. certs es donde
vivirán los certificados emitidos. vhost permite override por host.
html sirve el ACME challenge de Let’s Encrypt.
Levantando Nginx en contenedor
Vamos a usar la imagen oficial nginx:1.27-alpine con config montada
vía bind mount. Así editas archivos en el host y recargas Nginx sin
rebuild de imagen.
Crea /opt/nginx-proxy/docker-compose.yml:
services:
nginx:
image: nginx:1.27-alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro
- ./vhost:/etc/nginx/vhost.d:ro
- ./html:/usr/share/nginx/html:ro
networks:
- proxy-net
networks:
proxy-net:
external: trueexternal: true le dice a Compose que la red ya existe (la creamos en
el paso 01) — sin esto, crea una nueva con prefijo del nombre del
proyecto y tu Nginx no ve los backends.
Crea un server block default en conf.d/default.conf:
server {
listen 80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
return 444;
}
}return 444 es el código no-estándar de Nginx que cierra la conexión
sin respuesta — útil para rechazar requests con Host desconocido (bots
escaneando IP directamente).
Levanta Nginx:
docker compose up -d
docker compose logs nginxLos logs deben mostrar start worker processes sin error. Si aparece
bind() to 0.0.0.0:80 failed, algo en el host está en el puerto —
vuelve atrás e investiga antes de continuar.
Conectando los backends al proxy
Los contenedores que ya tienes corriendo probablemente están en la red
default (bridge) o en alguna creada por su Compose. Tienen que
entrar también en proxy-net para que Nginx los resuelva por nombre.
Agrega proxy-net al compose de cada backend. Ejemplo para una API:
services:
api:
image: mi-api:latest
container_name: api-backend
restart: unless-stopped
networks:
- default
- proxy-net
# NOTA: sin `ports:` — no publica en el host
networks:
proxy-net:
external: trueRecrea el contenedor: docker compose up -d --force-recreate. Mantiene
la red default para dependencias internas (base de datos, redis) y
agrega proxy-net para ser alcanzable por Nginx.
Valida que Nginx ve el backend por su nombre:
docker exec nginx-proxy ping -c 2 api-backendUna respuesta con IP 172.x.x.x significa que el DNS de la red
funciona. bad address significa que el backend no está en
proxy-net — vuelve atrás y revisa el docker network inspect proxy-net.
Configurando el reverse proxy por dominio
Cada dominio se vuelve un archivo separado en conf.d/. Esto facilita
el versionamiento y el debug — lees solo el vhost relevante.
Crea conf.d/api.ejemplo.com.conf:
upstream api_backend {
server api-backend:3000;
keepalive 32;
}
server {
listen 80;
server_name api.ejemplo.com;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name api.ejemplo.com;
ssl_certificate /etc/nginx/certs/api.ejemplo.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/api.ejemplo.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
location / {
proxy_pass http://api_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 Connection "";
proxy_read_timeout 60s;
}
}El upstream con nombre y keepalive 32 mantiene un pool de
conexiones reutilizables hacia el backend — reduce la latencia en 5-15ms
por request frente a abrir un nuevo TCP cada vez. proxy_http_version 1.1
Connection ""son obligatorios para que keepalive funcione.
Para admin.ejemplo.com y ejemplo.com, copia el archivo cambiando
server_name, upstream y la ruta del certificado. No intentes poner
varios server_name en el mismo bloque — Let’s Encrypt emite por
nombre, y eso complica la renovación.
Emitiendo certificados TLS
Vamos a usar certbot standalone vía contenedor, evitando instalar
Python en el host. Nginx atiende el challenge en
/.well-known/acme-challenge/ que configuramos en el paso 04.
Emite el certificado para cada dominio:
docker run --rm \
-v /opt/nginx-proxy/certs:/etc/letsencrypt \
-v /opt/nginx-proxy/html:/var/www/html \
certbot/certbot certonly \
--webroot -w /var/www/html \
-d api.ejemplo.com \
--email [email protected] \
--agree-tos --no-eff-emailLos archivos fullchain.pem y privkey.pem aparecen en
/opt/nginx-proxy/certs/live/api.ejemplo.com/. Ajusta el
ssl_certificate del server block para apuntar a live/ en lugar de
la ruta que puse en el ejemplo (que era simplificada).
Valida la config y recarga:
docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reloadnginx -t parsea todo sin aplicar — si aparece un error de sintaxis o
un archivo de cert no encontrado, rechaza el reload y mantiene la
config anterior corriendo. Cero downtime garantizado.
Agrega un cron diario que ejecute certbot renew --webroot -w /var/www/html
y a continuación docker exec nginx-proxy nginx -s reload. Let’s
Encrypt expira a los 90 días — renovar cada 60 te da margen si algún
proveedor de DNS se retrasa.
Verificación
Prueba cada dominio externamente:
curl -I https://api.ejemplo.com/healthz
Deberías ver HTTP/2 200, server: nginx y los headers de tu
backend. Si aparece 502 Bad Gateway, Nginx llegó pero el upstream
cayó — docker logs api-backend muestra qué pasó. Si aparece
504 Gateway Timeout, el backend está vivo pero no respondió en 60s —
ajusta proxy_read_timeout o investiga lentitud en la aplicación.
Para confirmar que la IP real del cliente está llegando al backend,
loguea X-Forwarded-For en la aplicación y haz un curl desde otro
host. Si aparece 172.x.x.x en vez de la IP pública del cliente, algún
proxy intermedio está reescribiendo — ajusta la confianza de proxy en
el framework del backend.
Próximos pasos
A partir de aquí, vale la pena habilitar rate limiting con
limit_req_zone para endpoints sensibles (/login, /api/admin),
agregar Brotli como módulo de compresión en reemplazo de gzip, separar
logs por dominio con access_log específico por server block, y mover
la config a un repositorio git versionado.
Si estás llevando esto a producción seria, una VPS Hostini ya viene con Docker preinstalado, protección DDoS en el borde filtrando ataques antes de que lleguen a Nginx, y snapshots automáticos para revertir cualquier config rota en segundos — explora los planes en /vps.
Preguntas frecuentes
¿Por qué usar Nginx delante de Docker en lugar de publicar puertos directamente?
Publicar puertos (-p 8080:80) funciona para un contenedor, pero no escala. Con Nginx delante usas un único puerto 80/443, enrutas varios dominios al mismo IP, terminas TLS en un solo lugar, centralizas logs de acceso y obtienes rate limiting, cache y WAF si lo deseas. Los contenedores quedan en una red aislada sin exponer puertos en el host.
¿Necesito Nginx corriendo en el host o también puedo usarlo en contenedor?
Ambos funcionan. Nginx en contenedor es más limpio (config versionada en el compose, fácil de levantar en otra máquina), pero exige que Nginx esté en la misma red Docker que los backends para resolver los nombres. Nginx en el host es más simple de depurar, pero tienes que apuntar a 127.0.0.1:puerto_publicado de cada contenedor. Esta guía usa Nginx en contenedor.
¿Cómo descubre Nginx la IP del contenedor backend?
Docker tiene DNS interno en cada red user-defined. Cuando defines un contenedor con nombre 'app' en la red 'proxy-net', otros contenedores de la misma red resuelven 'app' a la IP actual vía DNS incorporado. Por eso el upstream de Nginx usa el nombre del contenedor, no una IP fija — Docker la reescribe cada vez que el contenedor reinicia.
¿Por qué necesito proxy_set_header Host y X-Forwarded-For?
Sin Host, el backend recibe Host: localhost (o nombre interno) y rompe cualquier lógica basada en dominio (cookies, redirects, multi-tenant). Sin X-Forwarded-For, el backend ve la IP de Nginx (172.x.x.x) en cada request — logs, rate limit y geoip quedan inútiles. Son los 2 headers obligatorios en cualquier reverse proxy.
¿Cómo recargo la config de Nginx sin tirar conexiones activas?
docker exec nginx-proxy nginx -s reload hace hot reload — el master process recompila la config y los workers antiguos finalizan las requests en vuelo antes de morir. Cero downtime. Antes del reload, ejecuta siempre docker exec nginx-proxy nginx -t para validar sintaxis — una config rota con reload deja a Nginx sirviendo la anterior, pero si un worker reinicia desde cero, falla.
¿Funciona con WebSocket y HTTP/2?
WebSocket exige proxy_set_header Upgrade y Connection en el location — sin eso el handshake falla y el cliente cae a long polling. HTTP/2 funciona out-of-the-box en listen 443 ssl http2, pero solo entre cliente y Nginx; de Nginx al backend sigue siendo HTTP/1.1 (lo cual está bien en el 99% de los casos). Para gRPC, necesitas grpc_pass en vez de proxy_pass.