Nginx (se pronuncia «engine-ex») es el programa que atiende las visitas en una buena parte de los servidores web del mundo. Hace dos trabajos: entrega archivos, como cualquier servidor web, y se pone delante de tus aplicaciones como proxy inverso, para recibir las conexiones, cifrarlas con HTTPS y repartirlas. Esta guía va de cero a una configuración de producción, con todo lo que se suele necesitar y con los errores que vas a ver, cada uno reproducido y explicado.
.test, que está reservado para pruebas; en tu servidor usarás el tuyo.- Qué es Nginx: servidor web y proxy inverso
- El laboratorio de esta guía
- Instalarlo
- Cómo está organizada la configuración
- Servir un sitio estático
- Varios sitios en un servidor
- Qué location atiende cada petición
- Proxy inverso hacia una app
- WebSockets
- Repartir la carga entre varias copias
- Comprimir y guardar en caché
- Límites: subidas y peticiones por segundo
- Los registros de acceso y de errores
- Comprobar y recargar sin cortar el servicio
- HTTPS con Let’s Encrypt y certbot
- La renovación automática
- HSTS, con cuidado
- Errores típicos y cómo depurarlos
- La configuración completa
Qué es Nginx: servidor web y proxy inverso
Cuando abres una página, tu navegador se conecta a un servidor por el puerto 80 (HTTP) o el 443 (HTTPS) y le pide una ruta. Si lo que pide es un archivo que ya existe, como una página HTML, una hoja de estilos o una imagen, Nginx lo busca en una carpeta y lo entrega: ese es el trabajo de servidor web. Si lo que pide lo tiene que calcular un programa, como el carrito de una tienda o una API, Nginx le pasa la petición a esa aplicación, espera la respuesta y se la devuelve al navegador: eso es un proxy inverso. Se llama «inverso» porque representa al servidor ante los visitantes, al revés que el proxy de una oficina, que representa a los empleados ante internet.
Poner Nginx delante de una app, aunque sea una sola, tiene ventajas claras: la app no queda expuesta directamente, el cifrado HTTPS se configura en un solo lugar, Nginx entrega los archivos estáticos mucho más rápido que la app, aguanta miles de conexiones lentas sin despeinarse y puede repartir el trabajo entre varias copias. Si los términos puerto, IP o DNS te suenan a chino, empieza por las guías de redes básicas y de DNS; si quieres saber qué viaja en cada petición, la de métodos HTTP.
El laboratorio de esta guía
Para que puedas reproducir todo, estas son las piezas. El servidor se llama servidor y responde a los nombres mitienda.test, blog.mitienda.test y pruebas.test; el cliente, con la IP 172.30.5.50, hace de visitante; app1 y app2 son dos copias de la misma app, que escucha en el puerto 3000 y responde con un JSON que dice quién es y qué cabeceras le llegaron. Las órdenes del servidor las ejecutó un usuario normal con sudo, como harías tú en un servidor en la nube.
El código de la app de prueba (Node, sin dependencias)
// App mínima para la guía de Nginx (2026-11-07): responde quién es, qué cabeceras le llegaron y acepta WebSocket.
// Sin dependencias: el apretón de manos de WebSocket se hace a mano con crypto (RFC 6455).
const http = require('http'), crypto = require('crypto');
const NOMBRE = process.env.NOMBRE || 'app';
const srv = http.createServer((req, res) => {
if (req.url.startsWith('/api/lento')) { setTimeout(() => res.end('por fin\n'), 10000); return; }
if (req.url.startsWith('/api/subir')) { let n = 0; req.on('data', c => n += c.length); req.on('end', () => res.end(`recibí ${n} bytes\n`)); return; }
const h = req.headers;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ instancia: NOMBRE, ruta: req.url, host: h['host'], ip_que_ve_la_app: req.socket.remoteAddress.replace('::ffff:', ''),
x_real_ip: h['x-real-ip'] || null, x_forwarded_for: h['x-forwarded-for'] || null, x_forwarded_proto: h['x-forwarded-proto'] || null }, null, 1) + '\n');
});
srv.on('upgrade', (req, sock) => {
const clave = req.headers['sec-websocket-key'];
const acepta = crypto.createHash('sha1').update(clave + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
sock.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + acepta + '\r\n\r\n');
const msg = Buffer.from(`hola desde ${NOMBRE} por WebSocket`);
sock.write(Buffer.concat([Buffer.from([0x81, msg.length]), msg]));
setTimeout(() => sock.end(), 300);
});
srv.listen(3000, () => console.log(`${NOMBRE} escuchando en el puerto 3000`));Instalarlo
En Debian y Ubuntu, Nginx viene en los repositorios oficiales:
sudo apt update sudo apt install nginx
nginx -v
salidanginx version: nginx/1.26.3
En un servidor normal, la instalación deja Nginx corriendo y configurado para arrancar con el sistema; lo compruebas con systemctl status nginx. Nuestro laboratorio no tiene systemd, así que lo arrancamos con sudo nginx. Desde el cliente ya responde la página de bienvenida:
curl -sI http://mitienda.test/
salidaHTTP/1.1 200 OK Server: nginx Date: Sat, 07 Nov 2026 16:51:42 GMT Content-Type: text/html Content-Length: 615 Last-Modified: Sat, 07 Nov 2026 16:51:35 GMT Connection: keep-alive ETag: "6aef5717-267" Accept-Ranges: bytes
Fíjate en el proceso: un proceso maestro, que corre como root para poder abrir los puertos 80 y 443 y leer la configuración, y varios procesos trabajadores (uno por núcleo del procesador, con worker_processes auto), que corren como el usuario sin privilegios www-data y son los que atienden las visitas:
ps -o user,pid,cmd -C nginx | head -4
salidaUSER PID CMD root 3460 nginx: master process nginx www-data 3461 nginx: worker process www-data 3462 nginx: worker process
Cómo está organizada la configuración
Todo vive en /etc/nginx:
ls /etc/nginx/
salidaconf.d fastcgi.conf fastcgi_params koi-utf koi-win mime.types modules-available modules-enabled nginx.conf proxy_params scgi_params sites-available sites-enabled snippets uwsgi_params win-utf
El archivo principal es nginx.conf. Sin comentarios, el de Debian queda así:
grep -nvE '^\s*(#|$)' /etc/nginx/nginx.conf
salida1:user www-data;
2:worker_processes auto;
3:worker_cpu_affinity auto;
4:pid /run/nginx.pid;
5:error_log /var/log/nginx/error.log;
6:include /etc/nginx/modules-enabled/*.conf;
8:events {
9: worker_connections 768;
11:}
13:http {
19: sendfile on;
20: tcp_nopush on;
21: types_hash_max_size 2048;
22: server_tokens off; # Recommended practice is to turn this off
27: include /etc/nginx/mime.types;
28: default_type application/octet-stream;
34: ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3 (POODLE), TLS 1.0, 1.1
35: ssl_prefer_server_ciphers off; # Don't force server cipher order.
41: access_log /var/log/nginx/access.log;
47: gzip on;
60: include /etc/nginx/conf.d/*.conf;
61: include /etc/nginx/sites-enabled/*;
62:}La configuración son directivas que terminan en punto y coma (worker_processes auto;) y bloques entre llaves que contienen otras directivas. Lo global va arriba; todo lo relacionado con la web va dentro de http { }; cada sitio es un bloque server { }, y dentro de cada sitio, los bloques location { } dicen qué hacer con cada ruta. Las dos líneas include del final son la clave del orden en Debian: lo que pongas en conf.d/*.conf se carga dentro de http (útil para ajustes generales) y cada sitio va en su propio archivo.
Los sitios se escriben en sites-available y se activan con un enlace simbólico en sites-enabled. Así puedes desactivar uno sin borrarlo:
ls -l /etc/nginx/sites-available/ /etc/nginx/sites-enabled/
salida/etc/nginx/sites-available/: total 4 -rw-r--r-- 1 root root 2412 Sep 15 10:37 default /etc/nginx/sites-enabled/: total 0 lrwxrwxrwx 1 root root 34 Nov 7 16:51 default -> /etc/nginx/sites-available/default
Servir un sitio estático
Creamos la carpeta del sitio con una página:
sudo mkdir -p /var/www/mitienda /var/www/blog
salidaecho "<h1>Mi tienda</h1>" | sudo tee /var/www/mitienda/index.html echo "<h1>El blog de la tienda</h1>" | sudo tee /var/www/blog/index.html <h1>Mi tienda</h1> <h1>El blog de la tienda</h1>
Y su configuración, en /etc/nginx/sites-available/mitienda.test (con sudo nano o el editor que uses):
server {
listen 80;
server_name mitienda.test www.mitienda.test;
root /var/www/mitienda;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}listen 80;: el puerto en el que escucha.server_name: los nombres a los que responde este bloque.root: la carpeta donde están los archivos. Una petición a/fotos/a.jpgse busca en/var/www/mitienda/fotos/a.jpg.try_files $uri $uri/ =404;: prueba el archivo, luego una carpeta con ese nombre (y suindex.html), y si no hay nada, responde 404.
Hacemos lo mismo con blog.mitienda.test en /var/www/blog, activamos los dos sitios y, antes de recargar, comprobamos la configuración con nginx -t:
sudo ln -s /etc/nginx/sites-available/mitienda.test /etc/nginx/sites-enabled/
salidasudo ln -s /etc/nginx/sites-available/blog.mitienda.test /etc/nginx/sites-enabled/ sudo nginx -t nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo nginx -s reload
salida2026/11/07 16:51:55 [notice] 3553#3553: signal process started
nginx -s reload le pide al proceso maestro que relea la configuración; en un servidor con systemd puedes usar también sudo systemctl reload nginx, que hace lo mismo. Ahora, desde el cliente:
curl -s http://mitienda.test/
salidacurl -s http://blog.mitienda.test/
curl -s -o /dev/null -w "%{http_code}\n" http://mitienda.test/no-existe.html
<h1>Mi tienda</h1>
<h1>El blog de la tienda</h1>
404Varios sitios en un servidor
Los dos sitios están en la misma máquina, con la misma IP y el mismo puerto. ¿Cómo sabe Nginx cuál entregar? Por la cabecera Host que manda el navegador, que contiene el nombre que escribiste. Nginx la compara con los server_name de cada bloque. Si nadie coincide, por ejemplo cuando alguien entra con la IP directamente, responde el bloque marcado como default_server, que en Debian es la página de bienvenida:
curl -s http://172.30.5.10/ | grep -o "<title>.*</title>"
salidacurl -s -H "Host: blog.mitienda.test" http://172.30.5.10/ <title>Welcome to nginx!</title> <h1>El blog de la tienda</h1>
Por eso conviene que el sitio por defecto no sea el tuyo: los robots que recorren internet entrando por IP verán la página de bienvenida (o un 444, si configuras return 444;, que cierra la conexión sin responder) y no tu tienda.
Qué location atiende cada petición
Dentro de un sitio puede haber muchos location, y una misma ruta puede coincidir con varios. La regla de Nginx no es «el primero que aparece», y es la fuente de muchas confusiones. Para verla en acción, hicimos un sitio de pruebas en el que cada location solo dice quién es:
server {
listen 80;
server_name pruebas.test;
default_type text/plain;
location = /contacto {
return 200 "1. coincidencia exacta (=)\n";
}
location ^~ /static/ {
return 200 "2. prefijo con ^~ (gana sin mirar expresiones)\n";
}
location ~* \.(png|jpg|css)$ {
return 200 "3. expresión regular (~*)\n";
}
location /static/img/ {
return 200 "4. prefijo largo, sin ^~\n";
}
location / {
return 200 "5. prefijo / (lo que nada más atrapa)\n";
}
}for ruta in /contacto /contacto/ /static/logo.png /static/img/foto.jpg /img/foto.jpg /static/img/leeme.txt /precios; do
salida printf "%-22s → " "$ruta"; curl -s "http://pruebas.test$ruta" done /contacto → 1. coincidencia exacta (=) /contacto/ → 5. prefijo / (lo que nada más atrapa) /static/logo.png → 2. prefijo con ^~ (gana sin mirar expresiones) /static/img/foto.jpg → 3. expresión regular (~*) /img/foto.jpg → 3. expresión regular (~*) /static/img/leeme.txt → 4. prefijo largo, sin ^~ /precios → 5. prefijo / (lo que nada más atrapa)
Lo interesante está en dos líneas. /contacto/, con barra al final, ya no es igual a /contacto, así que la coincidencia exacta no aplica. Y /static/img/foto.jpg no la atendió el bloque ^~ /static/, como muchos esperarían, sino la expresión regular: el prefijo más largo que coincide es /static/img/, que no lleva ^~, así que Nginx siguió adelante y probó las expresiones regulares. El ^~ solo protege cuando su prefijo es el más largo.
Proxy inverso hacia una app
Ahora la parte más útil: mandar /api/ a la app. La forma mínima es una sola línea:
location /api/ {
proxy_pass http://app1:3000;
}curl -s http://mitienda.test/api/hola
lo que recibe la app{
"instancia": "app1",
"ruta": "/api/hola",
"host": "app1:3000",
"ip_que_ve_la_app": "172.30.5.10",
"x_real_ip": null,
"x_forwarded_for": null,
"x_forwarded_proto": null
}Funciona, pero la app está ciega: ve como visitante la IP de Nginx (172.30.5.10) y no la del cliente, cree que la llamaron app1:3000 en lugar de mitienda.test y no sabe si el visitante usó HTTPS. Si la app genera enlaces, registra IP o limita intentos por IP, todo sale mal. La solución son cuatro cabeceras:
location /api/ {
proxy_pass http://app1: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;
}curl -s http://mitienda.test/api/hola
lo que recibe la app{
"instancia": "app1",
"ruta": "/api/hola",
"host": "mitienda.test",
"ip_que_ve_la_app": "172.30.5.10",
"x_real_ip": "172.30.5.50",
"x_forwarded_for": "172.30.5.50",
"x_forwarded_proto": "http"
}Debian trae esas mismas cuatro líneas listas en un archivo, para escribir solo include proxy_params;:
grep -v '^#' /etc/nginx/proxy_params | grep .
salidaproxy_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;
X-Forwarded-For inventado. Por eso la app debe escuchar solo en la red interna o en 127.0.0.1, y los frameworks tienen un ajuste para aceptar esas cabeceras solo de tu proxy (en Express, trust proxy; en Django, SECURE_PROXY_SSL_HEADER).La barra final de proxy_pass cambia la ruta. Sin nada después del puerto, la app recibe la ruta completa (/api/hola). Con una barra al final, Nginx quita la parte que coincidió con el location:
location /v2/ {
proxy_pass http://app1:3000/;
include proxy_params;
}curl -s http://mitienda.test/v2/hola | grep ruta
lo que recibe la app al pedir /v2/hola "ruta": "/hola",
WebSockets
Los WebSockets, la conexión permanente que usan los chats, las notificaciones en vivo o las herramientas de desarrollo, empiezan como una petición HTTP normal que pide «cambiar de protocolo» con las cabeceras Upgrade y Connection. Esas dos cabeceras son de las que un proxy no reenvía por defecto, así que hay que pasarlas a mano, y con HTTP/1.1:
location /ws/ {
proxy_pass http://app1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}Comparamos con un location /ws-mal/ que solo tiene proxy_pass:
for ruta in /ws/ /ws-mal/; do
salida echo "== $ruta"
curl -s --max-time 2 -o /dev/null -D - -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"http://mitienda.test$ruta" | grep -iE "^HTTP|^upgrade|^content-type"
done
== /ws/
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
== /ws-mal/
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8Con la configuración correcta, la app contesta 101 Switching Protocols y manda su mensaje; sin ella, recibe una petición normal y responde con su JSON de siempre, y el navegador da la conexión por fallida. El proxy_read_timeout 1h evita que Nginx corte a los 60 segundos (su valor por defecto) una conexión que puede pasar mucho tiempo callada.
curl -sN --max-time 2 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" http://mitienda.test/ws/ | tr -dc "[:print:]"; echo
el mensaje que llega por el WebSockethola desde app1 por WebSocket
Repartir la carga entre varias copias
Si tu app corre en varias copias, un bloque upstream (dentro de http, por ejemplo al principio del archivo del sitio) las agrupa con un nombre, y proxy_pass apunta a ese nombre:
upstream tienda_app {
zone tienda_app 64k;
server app1:3000;
server app2:3000;
}
server {
# ...
location /api/ {
proxy_pass http://tienda_app;
include proxy_params;
}
}for i in 1 2 3 4; do curl -s http://mitienda.test/api/ | grep instancia; done
salida "instancia": "app1", "instancia": "app2", "instancia": "app1", "instancia": "app2",
zone importa más de lo que parece. En nuestra primera prueba no la pusimos y las cuatro peticiones fueron a app1. No era un error: sin zone, cada proceso trabajador lleva su propia cuenta del reparto, y como cada petición nueva de curl caía en un trabajador distinto, todas eran «la primera» de su trabajador. Con zone, los trabajadores comparten la cuenta en memoria y el reparto se alterna como esperas. En un servidor con mucho tráfico se nota menos, pero el reparto queda más parejo.Si una copia se cae, Nginx la marca como no disponible y manda todo a las demás. Apagamos app2:
for i in 1 2 3 4; do curl -s -o /dev/null -w "%{http_code} " http://mitienda.test/api/; done; echosalida200 200 200 200
El visitante no notó nada: cuatro respuestas 200. El fallo quedó en el registro de errores:
sudo grep -c . /var/log/nginx/error.log; sudo grep upstream /var/log/nginx/error.log | tail -n 1 | cut -c1-260
salida1 2026/11/07 16:53:56 [error] 3732#3732: *55 connect() failed (113: No route to host) while connecting to upstream, client: 172.30.5.50, server: mitienda.test, request: "GET /api/ HTTP/1.1", upstream: "http://172.30.5.3:3000/api/", host: "mitienda.test"
Además del reparto por turnos, existen least_conn; (a la copia con menos conexiones abiertas) e ip_hash; (el mismo visitante siempre a la misma copia, útil si la app guarda sesiones en memoria).
Comprimir y guardar en caché
Debian trae gzip on;, pero con su configuración de fábrica solo comprime HTML: la línea gzip_types viene comentada.
grep -n gzip /etc/nginx/nginx.conf
salida47: gzip on; 49: # gzip_vary on; 50: # gzip_proxied any; 51: # gzip_comp_level 6; 52: # gzip_buffers 16 8k; 53: # gzip_http_version 1.1; 54: # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
Una hoja de estilos de 7.6 KB sale tal cual:
curl -s -o /dev/null -H "Accept-Encoding: gzip" -D - http://mitienda.test/estilos.css | grep -iE "^(content-type|content-length|content-encoding)"
salidaContent-Type: text/css Content-Length: 7692
Los ajustes generales van bien en un archivo de conf.d, que se carga dentro de http. Este activa gzip para los tipos de texto y prepara una caché para el proxy:
# /etc/nginx/conf.d/rendimiento.conf: se carga dentro del bloque http gzip_vary on; gzip_comp_level 5; gzip_min_length 1024; gzip_types text/css application/javascript application/json image/svg+xml text/plain; proxy_cache_path /var/cache/nginx/tienda levels=1:2 keys_zone=tienda:10m max_size=200m inactive=10m;
Y en el sitio, una vida larga en el navegador para los archivos estáticos y caché para una ruta de la API que cambia poco:
location ~* \.(css|js|png|jpg|webp|svg|woff2)$ {
expires 30d;
}
location /api/catalogo {
proxy_pass http://tienda_app;
include proxy_params;
proxy_cache tienda;
proxy_cache_valid 200 1m;
add_header X-Cache-Status $upstream_cache_status;
}Al probar, el primer tropiezo:
sudo nginx -t
salidanginx: the configuration file /etc/nginx/nginx.conf syntax is ok 2026/11/07 16:54:21 [emerg] 3785#3785: mkdir() "/var/cache/nginx/tienda" failed (2: No such file or directory) nginx: configuration file /etc/nginx/nginx.conf test failed
La carpeta /var/cache/nginx no existe en Debian (en la imagen oficial de Docker y en Red Hat, sí). Nginx crea la última carpeta de la ruta, pero no las de en medio:
sudo mkdir -p /var/cache/nginx
salidasudo nginx -t && sudo nginx -s reload nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 2026/11/07 16:54:21 [notice] 3799#3799: signal process started
curl -s -o /dev/null -H "Accept-Encoding: gzip" -D - http://mitienda.test/estilos.css | grep -iE "^(content-type|content-length|content-encoding|expires|cache-control)"
salidacurl -s -H "Accept-Encoding: gzip" http://mitienda.test/estilos.css | wc -c Content-Type: text/css Expires: Mon, 07 Dec 2026 16:54:23 GMT Cache-Control: max-age=2592000 Content-Encoding: gzip 382
La hoja viajó comprimida en 382 bytes en lugar de 7,692, y el navegador sabe que puede guardarla 30 días. expires conviene usarlo con archivos que cambian de nombre al cambiar de contenido (estilos.3f9a.css); si no, tus visitantes verán la versión vieja hasta que caduque. La caché del proxy se ve en la cabecera que añadimos:
for i in 1 2 3; do curl -s -D - -o /dev/null http://mitienda.test/api/catalogo | grep -i x-cache-status; done
salidaX-Cache-Status: MISS X-Cache-Status: HIT X-Cache-Status: HIT
La primera petición fue a la app (MISS) y las siguientes las respondió Nginx de su caché (HIT), durante un minuto. Nunca guardes en caché respuestas que dependen del usuario, como su carrito o su perfil: Nginx no sabe de sesiones y le mostraría a uno lo de otro.
Límites: subidas y peticiones por segundo
Nginx rechaza por defecto los cuerpos de más de 1 MB. Al subir una foto de 2 MB:
ls -lh foto.bin | awk "{print \$5, \$9}"salidacurl -s -o /dev/null -w "%{http_code}\n" --data-binary @foto.bin http://mitienda.test/api/subir
2.0M foto.bin
413sudo tail -n 1 /var/log/nginx/error.log | cut -c1-200
salida2026/11/07 16:54:33 [error] 3807#3807: *71 client intended to send too large body: 2000000 bytes, client: 172.30.5.50, server: mitienda.test, request: "POST /api/subir HTTP/1.1", host: "mitienda.test"
El límite se cambia con client_max_body_size, mejor solo en la ruta que recibe archivos que en todo el sitio. Y para frenar a quien intenta adivinar contraseñas, limit_req limita las peticiones por segundo de cada IP. La zona se define en http:
# /etc/nginx/conf.d/limites.conf: se carga dentro del bloque http limit_req_zone $binary_remote_addr zone=por_ip:10m rate=5r/s; limit_req_status 429;
location /api/subir {
client_max_body_size 20m;
proxy_pass http://tienda_app;
include proxy_params;
}
location /api/login {
limit_req zone=por_ip burst=5 nodelay;
proxy_pass http://tienda_app;
include proxy_params;
}curl -s --data-binary @foto.bin http://mitienda.test/api/subir
salidacurl -s -o /dev/null -w "%{http_code}\n" --data-binary @video.bin http://mitienda.test/api/subir
recibí 2000000 bytes
413La foto de 2 MB ya entra; un video de 30 MB sigue rebotando, que es lo que queremos. Y quince peticiones seguidas al inicio de sesión:
for i in $(seq 1 15); do curl -s -o /dev/null -w "%{http_code} " http://mitienda.test/api/login; done; echosalida200 200 200 200 200 200 429 429 429 429 429 429 429 429 429
La tasa es de 5 por segundo y burst=5 deja pasar 5 de más de golpe: pasaron 6 y las otras 9 recibieron 429 Too Many Requests (por defecto sería 503, que confunde porque parece una caída). Cada rechazo queda anotado:
sudo grep 'limiting requests' /var/log/nginx/error.log | tail -n 1 | cut -c1-220
salida2026/11/07 16:54:36 [error] 3837#3837: *95 limiting requests, excess: 5.255 by zone "por_ip", client: 172.30.5.50, server: mitienda.test, request: "GET /api/login HTTP/1.1", host: "mitienda.test"
Los registros de acceso y de errores
Nginx anota cada petición en el registro de acceso y cada problema en el de errores. El formato de fábrica, el mismo que usa Apache, dice quién, cuándo, qué pidió, qué se le respondió y con qué tamaño:
sudo tail -n 2 /var/log/nginx/access.log
salida172.30.5.50 - - [07/Nov/2026:16:54:36 +0000] "GET /api/login HTTP/1.1" 429 162 "-" "curl/8.14.1" 172.30.5.50 - - [07/Nov/2026:16:54:36 +0000] "GET /api/login HTTP/1.1" 429 162 "-" "curl/8.14.1"
Para un proxy es muy útil saber además cuánto tardó la app y cuál de las copias atendió. Definimos un formato propio en conf.d y le damos al sitio sus propios archivos:
# /etc/nginx/conf.d/registro.conf: un formato que además anota cuánto tardó la app
log_format tiempos '$remote_addr [$time_local] "$request" $status $body_bytes_sent '
'tiempo=$request_time app=$upstream_addr app_tardo=$upstream_response_time'; access_log /var/log/nginx/mitienda.access.log tiempos;
error_log /var/log/nginx/mitienda.error.log;sudo tail -n 3 /var/log/nginx/mitienda.access.log
salida172.30.5.50 [07/Nov/2026:16:54:47 +0000] "GET / HTTP/1.1" 200 19 tiempo=0.000 app=- app_tardo=- 172.30.5.50 [07/Nov/2026:16:54:47 +0000] "GET /api/hola HTTP/1.1" 200 214 tiempo=0.001 app=172.30.5.2:3000 app_tardo=0.000 172.30.5.50 [07/Nov/2026:16:54:47 +0000] "GET /api/hola HTTP/1.1" 200 214 tiempo=0.001 app=172.30.5.3:3000 app_tardo=0.001
La primera línea la sirvió Nginx solo (app=-); las otras dos fueron a copias distintas de la app. Cuando una página va lenta, comparar tiempo con app_tardo te dice al instante si el problema es la app o la red. Para leer estos registros a fondo, sirven las mismas técnicas de la guía de registros del servidor y la de logs y configuración; en Debian, logrotate los rota cada día y guarda dos semanas.
Comprobar y recargar sin cortar el servicio
La costumbre más sana con Nginx es siempre nginx -t antes de recargar. Con una directiva mal escrita:
sudo nginx -t
salida2026/11/07 16:55:04 [emerg] 3997#3997: unknown directive "proxy_pas" in /etc/nginx/sites-enabled/mitienda.test:26 nginx: configuration file /etc/nginx/nginx.conf test failed
¿Y si recargas de todos modos? El proceso maestro rechaza la configuración nueva y sigue con la anterior. El sitio no se cae:
sudo nginx -s reload; sleep 1; curl -s -H "Host: mitienda.test" http://127.0.0.1/
salida2026/11/07 16:55:18 [emerg] 4070#4070: unknown directive "proxy_pas" in /etc/nginx/sites-enabled/mitienda.test:26 <h1>Mi tienda</h1>
Ojo con restart: systemctl restart nginx detiene Nginx y lo vuelve a arrancar, y si la configuración tiene un error, ya no arranca. reload es el que hay que usar en el día a día.
nginx -t no lo ve todo. Quitamos el punto y coma de server_name mitienda.test www.mitienda.test;, y el resultado fue «test is successful» con solo un aviso. Nginx siguió leyendo hasta el siguiente punto y coma y tomó la línea de abajo como más nombres del sitio:sudo nginx -t
salida2026/11/07 16:54:55 [warn] 3922#3922: server name "/var/log/nginx/mitienda.access.log" has suspicious symbols in /etc/nginx/sites-enabled/mitienda.test:11 nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful
Lee los avisos ([warn]), no solo la última línea. Con sudo nginx -T, con T mayúscula, Nginx imprime la configuración completa ya armada, con todos los include, que es la forma más rápida de ver qué está cargando de verdad.
Y la prueba de que recargar no corta nada: desde el cliente lanzamos 600 peticiones seguidas mientras en el servidor recargábamos cinco veces.
for i in $(seq 1 600); do
curl -s -o /dev/null -w "%{http_code}\n" http://mitienda.test/api/hola >> codigos.txt
donefor i in 1 2 3 4 5; do sudo nginx -s reload 2>/dev/null; sleep 0.4; done; echo "5 recargas hechas"
salida5 recargas hechas
sort codigos.txt | uniq -c
códigos de respuesta en el cliente 600 200
Las 600 respondieron 200. Al recargar, el maestro arranca trabajadores nuevos con la configuración nueva y deja que los viejos terminen lo que estaban atendiendo antes de cerrarse.
HTTPS con Let’s Encrypt y certbot
Let’s Encrypt da certificados gratuitos y certbot los pide, los instala en Nginx y los renueva. El método más común, el reto HTTP-01, prueba que el dominio es tuyo pidiéndote que publiques un archivo en tu sitio:
Para que funcione, el dominio debe apuntar a tu servidor (un registro A en tu DNS) y el puerto 80 debe estar abierto, aunque luego todo vaya por HTTPS. En Debian y Ubuntu:
sudo apt install certbot python3-certbot-nginx
sudo apt-get install -y certbot python3-certbot-nginx >/dev/null 2>&1; certbot --version
salidacertbot 4.0.0
En tu servidor, la orden sería esta (con tu dominio y tu correo, para los avisos de vencimiento):
sudo certbot --nginx -d mitienda.com -d www.mitienda.com
En el laboratorio añadimos --server para usar Pebble en lugar de Let’s Encrypt (y le dijimos al servidor que confiara en el certificado de prueba de Pebble), además de las respuestas a las preguntas que certbot hace la primera vez:
sudo certbot --nginx -d mitienda.test --server https://pebble:14000/dir --agree-tos -m admin@mitienda.test --no-eff-email --redirect
salidaSaving debug log to /var/log/letsencrypt/letsencrypt.log Account registered. Requesting a certificate for mitienda.test Successfully received certificate. Certificate is saved at: /etc/letsencrypt/live/mitienda.test/fullchain.pem Key is saved at: /etc/letsencrypt/live/mitienda.test/privkey.pem This certificate expires on 2027-02-05. These files will be updated when the certificate renews. Certbot has set up a scheduled task to automatically renew this certificate in the background. Deploying certificate Successfully deployed certificate for mitienda.test to /etc/nginx/sites-enabled/mitienda.test Congratulations! You have successfully enabled HTTPS on https://mitienda.test - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - If you like Certbot, please consider supporting our work by: * Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate * Donating to EFF: https://eff.org/donate-le - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Pebble, igual que Let’s Encrypt, descargó el reto desde varios puntos. Se ve en el registro de acceso: tres visitas al mismo token.
sudo grep acme-challenge /var/log/nginx/mitienda.access.log | cut -c1-160
salida172.30.5.4 [07/Nov/2026:16:56:54 +0000] "GET /.well-known/acme-challenge/LElzXf85uHD_w35KbY9KANSo_I67BUVXJpQ5i9zmv-M HTTP/1.1" 200 87 tiempo=0.000 app=- app_tar 172.30.5.4 [07/Nov/2026:16:56:54 +0000] "GET /.well-known/acme-challenge/LElzXf85uHD_w35KbY9KANSo_I67BUVXJpQ5i9zmv-M HTTP/1.1" 200 87 tiempo=0.000 app=- app_tar 172.30.5.4 [07/Nov/2026:16:56:54 +0000] "GET /.well-known/acme-challenge/LElzXf85uHD_w35KbY9KANSo_I67BUVXJpQ5i9zmv-M HTTP/1.1" 200 87 tiempo=0.000 app=- app_tar
Esto es lo que certbot cambió en la configuración del sitio: movió el sitio al puerto 443 con su certificado y creó un bloque nuevo en el 80 que redirige todo a HTTPS (por la opción --redirect, que es la recomendable).
diff /tmp/antes-certbot /etc/nginx/sites-available/mitienda.test
salida8d7
< listen 80;
82a82,88
>
> listen 443 ssl; # managed by Certbot
> ssl_certificate /etc/letsencrypt/live/mitienda.test/fullchain.pem; # managed by Certbot
> ssl_certificate_key /etc/letsencrypt/live/mitienda.test/privkey.pem; # managed by Certbot
> include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
> ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
>
83a90,103
>
>
> server {
> if ($host = mitienda.test) {
> return 301 https://$host$request_uri;
> } # managed by Certbot
>
>
> listen 80;
> server_name mitienda.test www.mitienda.test;
> return 404; # managed by Certbot
>
>
> }
\ No newline at end of filecurl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://mitienda.test/salidacurl -s --cacert raiz-pebble.pem https://mitienda.test/ curl -s --cacert raiz-pebble.pem https://mitienda.test/api/hola | grep forwarded_proto 301 https://mitienda.test/ <h1>Mi tienda</h1> "x_forwarded_proto": "https"
HTTP redirige con un 301, HTTPS responde y la app ya ve "x_forwarded_proto": "https", así que sabe que la visita llegó cifrada aunque Nginx le hable por HTTP en la red interna. El certificado:
curl -sv --cacert raiz-pebble.pem https://mitienda.test/ -o /dev/null 2>&1 | grep -E "SSL connection|subject:|issuer:|expire date|verify ok"
salida* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey * subject: * expire date: Feb 5 16:56:55 2027 GMT * issuer: CN=Pebble Intermediate CA 7bbc49 * SSL certificate verify ok.
Un cliente que no confía en quien firmó el certificado lo rechaza. Es lo que ocurre aquí si no le pasamos a curl la raíz de Pebble; con un certificado real de Let’s Encrypt no pasa, porque su raíz ya viene en todos los sistemas (lo explicamos en cómo sabe tu navegador en qué certificados confiar):
curl -sS https://mitienda.test/ -o /dev/null
salidacurl: (60) SSL certificate problem: unable to get local issuer certificate More details here: https://curl.se/docs/sslcerts.html curl failed to verify the legitimacy of the server and therefore could not establish a secure connection to it. To learn more about this situation and how to fix it, please visit the webpage mentioned above.
mitienda.test, y el bloque que creó certbot en el puerto 80 responde 404 a www.mitienda.test, porque ese nombre no quedó en el certificado. En tu servidor, incluye cada nombre con su -d. Para entender qué pasa dentro de la conexión cifrada, sigue con HTTPS por dentro.La renovación automática
Los certificados de Let’s Encrypt duran 90 días y certbot los renueva solo cuando les quedan menos de 30. Al instalarlo, deja una tarea que lo intenta dos veces al día: un temporizador de systemd y, para sistemas sin systemd, una tarea de cron.
cat /usr/lib/systemd/system/certbot.timer; grep -v "^#" /etc/cron.d/certbot | grep .
salida[Unit] Description=Run certbot twice daily [Timer] OnCalendar=*-*-* 00,12:00:00 RandomizedDelaySec=43200 Persistent=true [Install] WantedBy=timers.target SHELL=/bin/sh PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin 0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(43200))' && certbot -q renew --no-random-sleep-on-renew
sudo certbot certificates 2>&1 | grep -vE "^-+$|^$"
salidaSaving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Found the following certs:
Certificate Name: mitienda.test
Serial Number: 1d3f2065f11bdf30
Key Type: ECDSA
Domains: mitienda.test
Expiry Date: 2027-02-05 16:56:55+00:00 (VALID: 89 days)
Certificate Path: /etc/letsencrypt/live/mitienda.test/fullchain.pem
Private Key Path: /etc/letsencrypt/live/mitienda.test/privkey.pem
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Lo recomendable es comprobar que la renovación funcionará con sudo certbot renew --dry-run, que hace todo el proceso contra el servidor de pruebas de Let’s Encrypt sin tocar tu certificado. En nuestro laboratorio fue justo ahí, al servidor de pruebas real, y este rechazó el dominio, porque .test no es un dominio público. Es la confirmación de que --dry-run habla con Let’s Encrypt de verdad:
salidaSaving debug log to /var/log/letsencrypt/letsencrypt.log Processing /etc/letsencrypt/renewal/mitienda.test.conf Account registered. Simulating renewal of an existing certificate for mitienda.test Failed to renew certificate mitienda.test with error: urn:ietf:params:acme:error:rejectedIdentifier :: The server will not issue certificates for the identifier :: Invalid identifiers requested :: Cannot issue for "mitienda.test": Domain name does not end with a valid public suffix (TLD) All simulated renewals failed. The following certificates could not be renewed: /etc/letsencrypt/live/mitienda.test/fullchain.pem (failure) 1 renew failure(s), 0 parse failure(s) Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.
Para ver la renovación completa, la forzamos contra Pebble, que es el servidor que quedó anotado en la configuración del certificado:
sudo grep -E '^(server|authenticator|installer)' /etc/letsencrypt/renewal/mitienda.test.conf
salidaserver = https://pebble:14000/dir authenticator = nginx installer = nginx
salidaSaving debug log to /var/log/letsencrypt/letsencrypt.log Processing /etc/letsencrypt/renewal/mitienda.test.conf Renewing an existing certificate for mitienda.test Reloading nginx server after certificate renewal Congratulations, all renewals succeeded: /etc/letsencrypt/live/mitienda.test/fullchain.pem (success)
Certbot renovó el certificado y recargó Nginx él solo. Si alguna vez falla (el puerto 80 cerrado, un DNS que cambió), Let’s Encrypt avisa por correo antes del vencimiento.
HSTS, con cuidado
HSTS es una cabecera que le dice al navegador «durante este tiempo, entra a este sitio solo por HTTPS». Protege contra quien intente desviar la primera visita sin cifrar, pero es una promesa difícil de retirar: si después tienes que volver a HTTP, los navegadores que la recibieron no te dejarán. Empieza con un día y súbelo poco a poco, hasta un año:
add_header Strict-Transport-Security "max-age=86400" always;
La pusimos en el bloque del sitio en el 443 y revisamos tres rutas:
for ruta in / /api/hola /api/catalogo; do
salida printf "%-14s " "$ruta"; curl -s -o /dev/null -D - --cacert raiz-pebble.pem "https://mitienda.test$ruta" | grep -i strict-transport || echo "(sin HSTS)" done / Strict-Transport-Security: max-age=86400 /api/hola Strict-Transport-Security: max-age=86400 /api/catalogo (sin HSTS)
En /api/catalogo no salió. La razón es una regla de Nginx que sorprende a casi todos: un add_header dentro de un location anula todos los add_header del server, no se suman. Ese location tenía su propio add_header X-Cache-Status, así que perdió HSTS. Hay que repetir las cabeceras en cada location que tenga las suyas, o reunirlas en un archivo y hacer include en ambos lugares. Lo mismo pasa con cualquier cabecera de seguridad.
Errores típicos y cómo depurarlos
Cuando algo falla, el código de respuesta dice qué tipo de problema es y el registro de errores dice exactamente cuál. Estos son los que más vas a ver, provocados a propósito en el laboratorio:
location /api/inventario {
proxy_pass http://127.0.0.1:4000; # una app que no arrancó
}
location /api/lento {
proxy_pass http://tienda_app;
proxy_read_timeout 3s; # la app tarda 10 s en contestar
}
location /descargas/ {
root /srv/archivos; # el archivo está en /srv/archivos/catalogo.pdf
}502 Bad Gateway: la app no contesta
curl -s -o /dev/null -w "%{http_code}\n" http://mitienda.test/api/inventariosalida502
sudo tail -n 1 /var/log/nginx/mitienda.error.log | cut -c1-240
salida2026/11/07 16:55:46 [error] 4230#4230: *1308 connect() failed (111: Connection refused) while connecting to upstream, client: 172.30.5.50, server: mitienda.test, request: "GET /api/inventario HTTP/1.1", upstream: "http://127.0.0.1:4000/api/
Connection refused significa que no hay nada escuchando en ese puerto: la app no arrancó, se cayó o escucha en otro puerto. Revisa que esté corriendo y en qué dirección escucha. Si dice No route to host, como cuando apagamos app2, el problema es de red: la máquina no existe o un firewall la tapa.
504 Gateway Timeout: la app tarda demasiado
curl -s -o /dev/null -w "%{http_code} en %{time_total} s\n" http://mitienda.test/api/lentosalida504 en 6.006186 s
sudo grep 'timed out' /var/log/nginx/mitienda.error.log | tail -n 2 | sed -E 's/^([0-9/: ]+) \[error\].*(upstream timed out \([^)]*\)).*upstream: ("[^"]*").*/\1 \2, upstream: \3/'salida2026/11/07 16:55:49 upstream timed out (110: Connection timed out), upstream: "http://172.30.5.2:3000/api/lento" 2026/11/07 16:55:52 upstream timed out (110: Connection timed out), upstream: "http://172.30.5.3:3000/api/lento"
Pusimos un tiempo máximo de 3 segundos y la respuesta tardó 6. No es un error de medición: cuando una copia no contesta a tiempo, Nginx reintenta con la siguiente del upstream, y las dos esperaron 3 segundos. Con peticiones que no deben repetirse, como un pago, esto puede ser peligroso; proxy_next_upstream off; lo desactiva. Si la app de verdad necesita más tiempo, sube proxy_read_timeout solo en esa ruta; si no, el problema está en la app.
413 Request Entity Too Large: el archivo es demasiado grande
Lo vimos en los límites: sube client_max_body_size en la ruta que recibe archivos. Si la app está detrás de otro proxy (Cloudflare, un balanceador), cada uno tiene su propio límite.
404 con root: el archivo existe, pero no lo encuentra
curl -s -o /dev/null -w "%{http_code}\n" http://mitienda.test/descargas/catalogo.pdfsalida404
sudo tail -n 1 /var/log/nginx/mitienda.error.log | cut -c1-240
salida2026/11/07 16:55:52 [error] 4232#4232: *1313 open() "/srv/archivos/descargas/catalogo.pdf" failed (2: No such file or directory), client: 172.30.5.50, server: mitienda.test, request: "GET /descargas/catalogo.pdf HTTP/1.1", host: "mitienda.t
El registro dice dónde buscó: /srv/archivos/descargas/catalogo.pdf. Con root, Nginx suma la ruta completa a la carpeta. Si quieres que /descargas/ se sustituya por la carpeta, es alias (con la barra final en los dos lados):
location /descargas/ {
alias /srv/archivos/;
}curl -s http://mitienda.test/descargas/catalogo.pdf
salidacatálogo de prueba
Responde el sitio equivocado: IPv6
Un caso que nos encontramos sin buscarlo: desde el propio servidor, curl http://localhost con el nombre de la tienda devolvía la página de bienvenida.
curl -s -H "Host: mitienda.test" http://127.0.0.1/
salidacurl -s -H "Host: mitienda.test" http://[::1]/ | grep -o "<title>.*</title>" <h1>Mi tienda</h1> <title>Welcome to nginx!</title>
localhost resolvió primero a ::1, la dirección IPv6, y nuestros sitios solo tenían listen 80;, que escucha en IPv4. En IPv6 solo escuchaba el sitio por defecto. Si tu servidor tiene IPv6, añade listen [::]:80; (y listen [::]:443 ssl;) a cada sitio, o tus visitantes con IPv6 verán otra cosa.
Cuando algo no cuadra, en este orden
sudo nginx -t, y lee también los avisos.sudo tail -f /var/log/nginx/error.log(o el del sitio) mientras repites la petición.curl -Idesde el servidor y desde fuera, con-H "Host: tusitio"para probar un sitio concreto.sudo nginx -T | lesspara ver la configuración que de verdad está cargada.ss -ltnppara ver qué programa escucha en cada puerto, y si tu app de verdad está en el que crees.
La configuración completa
Así quedó el archivo del sitio al final del laboratorio, después de certbot y de HSTS. Puedes usarlo de plantilla: quita lo que no necesites y cambia los nombres por los tuyos. Los location de errores (/api/inventario y /api/lento) y de comparación (/v2/ y /ws-mal/) estaban solo para las pruebas.
upstream tienda_app {
zone tienda_app 64k;
server app1:3000;
server app2:3000;
}
server {
server_name mitienda.test www.mitienda.test;
access_log /var/log/nginx/mitienda.access.log tiempos;
error_log /var/log/nginx/mitienda.error.log;
root /var/www/mitienda;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(css|js|png|jpg|webp|svg|woff2)$ {
expires 30d;
}
location /api/catalogo {
proxy_pass http://tienda_app;
include proxy_params;
proxy_cache tienda;
proxy_cache_valid 200 1m;
add_header X-Cache-Status $upstream_cache_status;
}
location /api/subir {
client_max_body_size 20m;
proxy_pass http://tienda_app;
include proxy_params;
}
location /api/login {
limit_req zone=por_ip burst=5 nodelay;
proxy_pass http://tienda_app;
include proxy_params;
}
location /api/inventario {
proxy_pass http://127.0.0.1:4000;
}
location /api/lento {
proxy_pass http://tienda_app;
proxy_read_timeout 3s;
}
location /descargas/ {
alias /srv/archivos/;
}
location /api/ {
proxy_pass http://tienda_app;
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 /v2/ {
proxy_pass http://app1:3000/;
include proxy_params;
}
location /ws/ {
proxy_pass http://app1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}
location /ws-mal/ {
proxy_pass http://app1:3000;
}
listen 443 ssl; # managed by Certbot
add_header Strict-Transport-Security "max-age=86400" always;
ssl_certificate /etc/letsencrypt/live/mitienda.test/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/mitienda.test/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = mitienda.test) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name mitienda.test www.mitienda.test;
return 404; # managed by Certbot
}Si vas a desplegar tu app con contenedores, la guía de Docker explica cómo construir y publicar la imagen; Nginx puede ir en otro contenedor delante, con la misma configuración. Y para entrar al servidor a hacer todo esto, la de SSH.
Fuentes: directiva location, módulo proxy, módulo upstream, limit_req, add_header y WebSocket en la documentación de Nginx; tipos de reto y entorno de pruebas de Let’s Encrypt; manual de certbot; Pebble; dominios .test, en el RFC 6761. Laboratorio: Debian 13, Nginx 1.26.3, certbot 4.0.0, curl 8.14.1 y Node 24, en contenedores desechables.
