Nginx desde cero: servidor web, proxy inverso y HTTPS con Let’s Encrypt

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.

Todo lo que ves aquí se ejecutó de verdad. Montamos un laboratorio con Debian 13 y Nginx 1.26.3, dos copias de una pequeña app en Node, un cliente con curl y Pebble, el servidor de pruebas de Let’s Encrypt, para obtener un certificado real sin tener un dominio público. Las salidas son las que dieron esas máquinas. Los dominios terminan en .test, que está reservado para pruebas; en tu servidor usarás el tuyo.

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.

Nginx en medio de todo Navegador mitienda.test puertos 80 y 443 Nginx atiende todas las conexiones, cifra con HTTPS, anota registros y decide a dónde va cada petición Servidor web entrega él mismo HTML, CSS, imágenes /var/www/mitienda /estilos.css Proxy inverso le pasa la petición a tu aplicación app1 app2 La app nunca queda expuesta a internet: escucha solo en la red interna (puerto 3000) y Nginx es su única puerta.

Esquema: decodigo.com.

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.

Bloques dentro de bloques nginx.conf lo global: usuario, procesos events { } conexiones por proceso http { } todo lo de la web include conf.d/*.conf include sites-enabled/* server { } un sitio listen 80; server_name mitienda.test; root /var/www/mitienda; location / { } qué hacer con cada ruta location /api/ { } proxy_pass a la app

Esquema: decodigo.com.

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.jpg se busca en /var/www/mitienda/fotos/a.jpg.
  • try_files $uri $uri/ =404;: prueba el archivo, luego una carpeta con ese nombre (y su index.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>
404

Varios 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)

Qué location atiende la petición 1 Coincidencia exacta ¿Hay un location = /ruta igual a la petición? Gana y se acaba. 2 El prefijo más largo De los location de prefijo, se queda con el más largo que coincida. 3 ¿Ese prefijo lleva ^~? Si lo lleva, gana sin mirar las expresiones regulares. 4 Expresiones regulares Se prueban ~ y ~* en el orden del archivo: la primera gana. 5 Si ninguna coincide gana el prefijo más largo del paso 2.

Esquema: decodigo.com, según la documentación de la directiva location.

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;
Del lado de la app, desconfía de esas cabeceras si alguien pudiera llegarle sin pasar por Nginx: cualquiera puede mandar un 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-8

Con 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",
La línea 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; echo
salida200 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
413
sudo 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
413

La 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; echo
salida200 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.

Pero 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
done
for 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:

Cómo se gana el certificado 1. certbot pide un certificado para mitienda.test 2. Let’s Encrypt responde con un reto: publica este token en tu sitio 3. certbot configura Nginx para servirlo en /.well-known/acme-challenge/ 4. Let’s Encrypt lo descarga por el puerto 80 desde varios lugares: el dominio es tuyo 5. Let’s Encrypt firma y entrega el certificado (válido por 90 días) 6. certbot lo instala en Nginx, lo recarga y lo renueva solo antes de que venza

Esquema: decodigo.com, según la documentación de Let’s Encrypt.

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 file
curl -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.
Pide todos los nombres a la vez. En el laboratorio solo pedimos 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/inventario
salida502
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/lento
salida504 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.pdf
salida404
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

  1. sudo nginx -t, y lee también los avisos.
  2. sudo tail -f /var/log/nginx/error.log (o el del sitio) mientras repites la petición.
  3. curl -I desde el servidor y desde fuera, con -H "Host: tusitio" para probar un sitio concreto.
  4. sudo nginx -T | less para ver la configuración que de verdad está cargada.
  5. ss -ltnp para 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.