Harper
PLAN DE FORMACIÓN Departamento de Infraestructura
Tutorial · NPM
Tutorial avanzado · Proxy reverso + TLS gestionado

Nginx Proxy Manager (NPM) sobre Proxmox — un solo punto de entrada TLS

NPM (Nginx Proxy Manager) es un contenedor Docker que da UI web para gestionar proxies reversos y TLS con Let's Encrypt sin línea de comando. Se pone al frente de tus servicios (Zabbix, GLPI, dashboards internos, apps del cliente) y expone cada uno por su propio FQDN bajo HTTPS. Renueva los certificados automáticamente. Es el patrón por defecto para clientes con múltiples servicios web en Harper.

NPMjc21/nginx-proxy-manager:2
SOUbuntu 24.04 LTS
RuntimeDocker Engine + compose plugin
DB internaSQLite (default) o MariaDB
Recursos VM1 vCPU · 2 GB · 20 GB
Fases12
Duración~1.5–2 horas
Requiere80/443 accesibles desde internet
Arquitectura del stack
flowchart TB USER["Internet - usuarios finales"] subgraph ROUTER ["Router / firewall del cliente"] NAT["NAT 80/443 - NPM"] end USER -->|"zbx.cliente.com
glpi.cliente.com
app.cliente.com"| ROUTER ROUTER --> NPM_PROXY["NPM proxy
:80 challenge
:443 TLS"] subgraph VM_NPM ["VM npm-lab - 192.168.0.232"] NPM_PROXY NPM_ADMIN["Admin UI :81
solo LAN / VPN"] end NPM_PROXY -->|"HTTP interno :80"| ZBX["VM Zabbix
192.168.0.230"] NPM_PROXY -->|"HTTP interno :80"| GLPI["VM GLPI
192.168.0.231"] NPM_PROXY -->|"HTTP interno"| APP["Cualquier otro
servicio web"] LE["Lets Encrypt"] NPM_PROXY -.->|"HTTP-01 / DNS-01"| LE
Tu progreso 0 / 12 · 0%
ⓘ Contexto arquitectónico — leer antes de la fase 1

Sin NPM, cada servicio se auto-gestiona su TLS: Zabbix corre Certbot, GLPI corre otro Certbot, la app del cliente corre otro. Cada uno tiene su vhost SSL, su cert, su renovación. Si dos servicios necesitan salir por el mismo puerto 443 público, ya no se puede.

Con NPM la topología cambia: NPM es el único que escucha en 443 público. Enruta cada FQDN al servicio interno (Zabbix, GLPI, etc.) por HTTP interno. Los servicios back-end pueden quedarse en HTTP puro dentro de la red del cliente. Un solo cert por FQDN (o un wildcard) gestionado desde la UI.

Este patrón se usa siempre que hay ≥ 2 servicios web en la misma red. Con 1 solo servicio la diferencia es marginal y puede usarse Certbot directo.

FQDN de la VM
npm.lab.harper.local
Ajusta al DNS del cliente
IP sugerida
192.168.0.232
Puertos NPM
80 · 443 · 81
80/443 tráfico proxy · 81 UI admin
FQDN externos
zbx.cliente.com · glpi.cliente.com
Deben apuntar por DNS a la IP pública del cliente
Backend
SQLite en volumen Docker
MariaDB opcional si hay >50 hosts
Data path
/opt/npm/{data,letsencrypt}
Backup nocturno de estos dos dirs
◆ NPM vs Traefik vs Caddy

Los tres hacen lo mismo (proxy reverso + TLS gestionado). Elegimos NPM por dos razones:

  • UI visual: Traefik y Caddy se configuran por archivo/labels; NPM tiene UI web para todo. En clientes que operan el servicio ellos, la UI reduce errores en la administración diaria.
  • Curva de aprendizaje corta: técnicos junior lo dominan en un día. Traefik requiere entender routing rules y middlewares antes de ser útil.

Traefik/Caddy son mejores cuando el cliente maneja infraestructura como código (GitOps). Para clientes con operación tradicional, NPM gana.

01

Clonar la VM desde la plantilla cloud-init en Proxmox

Meta: VM Ubuntu 24.04 con IP 192.168.0.232, hostname npm-lab, llave SSH lista.

Recursos más chicos que Zabbix/GLPI porque NPM es un solo container liviano: 1 vCPU / 2 GB / 20 GB alcanzan sobrado para < 200 proxy hosts.

Ejecutar enhost: proxmox (SSH root@pve)
qm clone 9000 232 \
  --name npm-lab \
  --description "Nginx Proxy Manager · Ubuntu 24.04 · lab Harper" \
  --full 0 --pool lab

qm set 232 --cores 1 --sockets 1 --cpu host --memory 2048 --balloon 0
qm resize 232 scsi0 20G

qm set 232 \
  --ciuser rbatista \
  --sshkeys ~/.ssh/dojo_authorized_keys \
  --ipconfig0 ip=192.168.0.232/24,gw=192.168.0.1 \
  --nameserver "192.168.0.1 1.1.1.1" \
  --searchdomain lab.harper.local

qm cloudinit update 232
qm set 232 --onboot 1
qm start 232

until qm agent 232 ping 2>/dev/null; do echo "esperando cloud-init..."; sleep 5; done
qm agent 232 network-get-interfaces | jq -r '.[] | select(.name!="lo").["ip-addresses"][]?."ip-address"'
✓ Verificación
Ejecutar enhost: local
ssh rbatista@192.168.0.232 "hostnamectl; lsb_release -d; ip -brief a"
02

Post-instalación: hostname, updates, hardening SSH, firewall

Meta: VM alineada al estándar Harper — updates aplicados, SSH solo por llave, UFW activo con 22/80/443 abiertos.

Ejecutar enhost: vm (ssh a npm-lab)
sudo hostnamectl set-hostname npm-lab.lab.harper.local
sudo timedatectl set-timezone America/Santo_Domingo

sudo apt update
sudo DEBIAN_FRONTEND=noninteractive apt -y full-upgrade
sudo apt -y install curl wget gnupg2 lsb-release ca-certificates \
  ufw fail2ban unattended-upgrades chrony jq htop net-tools

# Sudo NOPASSWD para el usuario base.
echo "rbatista ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/90-rbatista
sudo chmod 440 /etc/sudoers.d/90-rbatista

# Hardening SSH.
sudo tee /etc/ssh/sshd_config.d/50-harper.conf >/dev/null <<'EOF'
PasswordAuthentication no
PermitRootLogin no
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
sudo sshd -t && sudo systemctl reload ssh

# UFW: SSH + los puertos que NPM va a exponer.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Admin UI: SOLO desde la LAN (o desde tu jump host). NUNCA público.
sudo ufw allow from 192.168.0.0/24 to any port 81 proto tcp
sudo ufw --force enable
sudo ufw status verbose

[ -f /var/run/reboot-required ] && sudo reboot
⚠ NUNCA abrir el puerto 81 al mundo

El puerto 81 sirve la UI de admin de NPM. Por defecto no hay TLS ahí (es HTTP puro) y la autenticación es un simple email+password. Exponerlo a internet es equivalente a dejar el panel de admin sin cifrar. Si necesitas acceso remoto, hazlo por VPN/WireGuard o por SSH tunnel (ssh -L 81:localhost:81 rbatista@npm-lab).

03

Instalar Docker Engine + Compose plugin (repo oficial)

Meta: docker y docker compose funcionando como el usuario rbatista sin sudo.

◆ Docker Engine (no Desktop, no docker.io del repo Ubuntu)

docker.io del repo Ubuntu queda atrás en versiones y sin el compose plugin oficial. Usamos el repo oficial de Docker Inc.: siempre estamos en la última minor y con compose v2 nativo (docker compose sin guion, no docker-compose).

3.1 Añadir el repo Docker

Ejecutar enhost: vm
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list

sudo apt update

3.2 Instalar Engine + Compose + Buildx

Ejecutar enhost: vm
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Añadir rbatista al grupo docker (para no sudo cada comando).
sudo usermod -aG docker rbatista

# Habilitar al boot.
sudo systemctl enable --now docker

# Verificar versiones.
sudo docker version
sudo docker compose version
Client: Docker Engine - Community Version: 27.5.1 API version: 1.47 ... Server: Docker Engine - Community Engine: Version: 27.5.1 ... Docker Compose version v2.32.4
💡 Re-loguear para que el grupo docker tenga efecto

Cierra la sesión SSH y vuelve a entrar (exit y ssh otra vez). Después: docker run hello-world debe funcionar SIN sudo.

✓ Verificación
Ejecutar enhost: vm (tras re-login)
docker run --rm hello-world
Hello from Docker! This message shows that your installation appears to be working correctly. ...
04

Desplegar NPM con docker compose (SQLite + volúmenes persistentes)

Meta: NPM levantado, UI accesible en http://192.168.0.232:81/.

4.1 Crear la estructura de directorios

Los dos volúmenes críticos son data/ (configs, SQLite, custom nginx snippets) y letsencrypt/ (certs emitidos). Todo lo demás es efímero.

Ejecutar enhost: vm
sudo mkdir -p /opt/npm/{data,letsencrypt}
sudo chown -R rbatista:rbatista /opt/npm
cd /opt/npm

4.2 Escribir el docker-compose.yml

Usamos SQLite (default). Es más que suficiente hasta ~200 proxy hosts. Si el cliente supera esa escala o quiere HA, se migra a MariaDB añadiendo un service db: y las variables DB_MYSQL_* (documentado al final del tutorial).

Ejecutar enhost: vm
tee /opt/npm/docker-compose.yml >/dev/null <<'EOF'
name: npm

services:
  app:
    image: 'jc21/nginx-proxy-manager:2'
    container_name: npm
    restart: unless-stopped
    ports:
      - '80:80'      # HTTP público (proxy)
      - '443:443'    # HTTPS público (proxy)
      - '81:81'      # Admin UI — protegida por UFW a la LAN
    environment:
      DISABLE_IPV6: 'true'          # simplifica logs en clientes IPv4-only
      TZ: America/Santo_Domingo
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    healthcheck:
      test: ["CMD", "/usr/bin/check-health"]
      interval: 30s
      timeout: 5s
      retries: 5
EOF

4.3 Levantar el stack

Ejecutar enhost: vm
cd /opt/npm
docker compose up -d
docker compose ps
docker compose logs -f --tail 20 app
# Ctrl+C para salir del log una vez veas "Backend PID X listening on port 3000"
NAME IMAGE STATUS npm jc21/nginx-proxy-manager:2 Up 12 seconds (health: starting) npm | ❯ Backend PID 62 listening on port 3000 ... npm | ❯ Nginx PID 44 running with configuration: /etc/nginx/nginx.conf
✓ Verificación

curl -I http://192.168.0.232:81/ devuelve 200 OK con server: nginx. En el navegador, la URL abre el login de NPM.

⚠ Si el container queda en health: starting indefinidamente

Revisa los logs: docker compose logs app --tail 100. Los dos errores más comunes:

  • "Error: EADDRINUSE 80": hay otro proceso ocupando el 80/443. Suele ser Nginx o Apache del sistema. Quitalo: sudo systemctl disable --now nginx apache2 2>/dev/null; sudo apt purge nginx apache2.
  • Migraciones de la DB fallando: borra ./data/database.sqlite y reintenta docker compose up -d. NPM la regenera limpia. (Solo en instalación limpia sin datos.)
05

Primer login y cambio inmediato del usuario admin

Meta: usuario admin del cliente creado con email real; el default admin@example.com eliminado.

5.1 Login default

NPM crea al primer boot un usuario admin con credenciales conocidas PÚBLICAMENTE — es el equivalente al zabbix/zabbix del otro tutorial. Cambiarlo es OBLIGATORIO antes de crear cualquier proxy host.

  1. Abrir http://192.168.0.232:81/ en el navegador.
  2. Login:
    • Email: admin@example.com
    • Password: changeme
  3. NPM fuerza cambio de datos en el mismo momento:
    • Full name: Ramces Batista (o el operador del cliente)
    • Nickname: Ramces
    • Email: infra@har-per.com (email real del cliente)
  4. Password nuevo, mínimo 8 chars, con complejidad.

5.2 Crear usuarios adicionales (opcional)

Si el cliente tiene un equipo de infra que va a operar NPM, crea usuarios por técnico con permisos limitados en vez de compartir el admin.

  1. Menú superior derecho → Users → Create User.
  2. Marcar los permisos por sección — para técnicos operativos suele bastar: Proxy Hosts: Manage, Access Lists: View Only, Streams: Hidden, Redirection Hosts: Manage, 404 Hosts: Hidden.
✓ Verificación

Cerrar sesión, intentar login con admin@example.com / changeme → debe fallar. Login con el email real y password nuevo → entra al dashboard vacío.

06

DNS y NAT/port-forwarding: prerrequisitos externos a la VM

Meta: los FQDNs que NPM va a manejar resuelven a la IP pública del cliente, y esa IP tiene el 80/443 forwardeados a NPM.

◆ Sin esto Let's Encrypt HTTP-01 no funciona

Let's Encrypt necesita llegar por HTTP al FQDN que va a certificar para validarlo (challenge HTTP-01). Si el DNS no resuelve o si el 80 no está abierto, la emisión del cert falla. Este paso ES el pre-requisito. Si eres nuevo en Harper y no lo tenías claro, este es el punto donde 80% de los tutoriales de NPM online se rompen.

6.1 Confirmar DNS público

Cada FQDN debe resolver a la IP pública del cliente. Ejemplo con Zabbix y GLPI:

FQDNTipoValorTTL
zbx.cliente.comAIP pública del cliente300
glpi.cliente.comAIP pública del cliente300
Ejecutar enhost: local
dig +short zbx.cliente.com
dig +short glpi.cliente.com
# Ambos deben devolver la IP pública del router del cliente.

6.2 Confirmar NAT/port-forward en el router del cliente

Todo tráfico entrante en 80/tcp y 443/tcp desde internet debe redirigirse a 192.168.0.232 (VM de NPM). Esto se configura en el router del cliente (FortiGate, Aruba, TP-Link, etc.). En FortiGate: Policy & Objects → Virtual IPs.

Ejecutar enhost: local (probar desde afuera)
# Test que el 80 esté abierto y llegue a NPM (que responde con la landing "Congratulations!" cuando no hay proxy host).
curl -I http://zbx.cliente.com/
# Debe responder 200 y contener 'nginx' en el header Server.
⚠ Si el curl devuelve timeout o "connection refused"
  • NAT/port-forward no está configurado — revisa router del cliente.
  • El ISP bloquea entrada en 80/443 (típico en conexiones residenciales). Necesitas internet corporativa o pedir cambio al ISP.
  • UFW en la VM está bloqueando — verifica sudo ufw status muestra 80/443 en ALLOW.
07

Emitir el primer certificado Let's Encrypt (por FQDN o wildcard)

Meta: cert emitido para zbx.cliente.com visible en SSL Certificates.

◆ HTTP-01 por FQDN vs DNS-01 wildcard

HTTP-01: Let's Encrypt valida cada FQDN con un archivo servido en http://FQDN/.well-known/acme-challenge/. Simple, funciona out-of-the-box con NPM. Requiere el 80 accesible desde internet. Un cert por FQDN.
DNS-01 wildcard: cert *.cliente.com que cubre todos los subdominios de una sola vez. Requiere que el proveedor DNS tenga API soportada por NPM (Cloudflare, Route53, DigitalOcean, etc.) y meter un API token. Ideal si el cliente va a tener ≥ 5 subdominios.

7.1 Ruta A · HTTP-01 (por FQDN, más simple)

  1. UI NPM → SSL Certificates → Add SSL Certificate → Let's Encrypt.
  2. Domain names: zbx.cliente.com (uno por línea si quieres varios).
  3. Email para Let's Encrypt: infra@har-per.com.
  4. Aceptar los TOS. Use DNS Challenge: OFF.
  5. Save. Espera ~15 segundos. Debe aparecer en verde bajo SSL Certificates.

7.2 Ruta B · DNS-01 wildcard (con Cloudflare, ejemplo)

  1. En Cloudflare: My Profile → API Tokens → Create Token → template "Edit zone DNS" → seleccionar la zona del cliente. Copiar el token.
  2. UI NPM → SSL Certificates → Add SSL Certificate → Let's Encrypt.
  3. Domain names: *.cliente.com, cliente.com.
  4. Use DNS Challenge: ON. DNS Provider: Cloudflare. Credentials File Content: dns_cloudflare_api_token=EL_TOKEN_COPIADO.
  5. Propagation seconds: 30.
  6. Save. Tarda ~1 minuto (espera a que el DNS propague).
✓ Verificación

El certificado aparece en SSL Certificates con expiración a ~90 días. NPM auto-renueva a los 60 días. Puedes forzar renovación con el botón Renew del menú de tres puntos.

08

Crear el primer Proxy Host: Zabbix

Meta: https://zbx.cliente.com/ abre el login de Zabbix. Grade A en SSLLabs.

8.1 Añadir el proxy host

  1. UI NPM → Hosts → Proxy Hosts → Add Proxy Host.
  2. Details:
    • Domain Names: zbx.cliente.com
    • Scheme: http
    • Forward Hostname/IP: 192.168.0.230 (IP de la VM Zabbix)
    • Forward Port: 80
    • Cache Assets: ON
    • Block Common Exploits: ON
    • Websockets Support: ON (Zabbix 7.0 lo usa en algunas secciones de la GUI)
  3. SSL:
    • SSL Certificate: seleccionar el cert emitido en la fase 7.
    • Force SSL: ON
    • HTTP/2 Support: ON
    • HSTS Enabled: ON (después de confirmar que todo funciona por HTTPS — activar HSTS antes puede dejar clientes cacheados en HTTPS si rompes algo).
    • HSTS Subdomains: OFF (solo activar si TODOS los subdominios del cliente estarán en HTTPS — poco común).
  4. Save.
✓ Verificación

Desde el navegador: https://zbx.cliente.com/ debe abrir el login de Zabbix con el cert válido (candado verde). http://zbx.cliente.com/ debe redirigir a HTTPS. Test SSL: ssllabs.com/ssltest — apuntar a zbx.cliente.com, esperar ~2 minutos, debe salir Grade A o A+.

⚠ Si Zabbix se queja de "Wrong host / server_name"

El vhost interno de Zabbix (fase 7 del tutorial Zabbix) tiene server_name zbx-lab.lab.harper.local 192.168.0.230;. Si NPM llega con el header Host: zbx.cliente.com, Zabbix responde bien porque el vhost es catch-all. Si tienes vhosts SNI múltiples en el back-end, añade zbx.cliente.com al server_name de Zabbix.

09

Segundo Proxy Host: GLPI (+ ajuste crítico para session.cookie_secure)

Meta: https://glpi.cliente.com/ funciona; GLPI acepta cookies seguras porque detecta correctamente que estamos en HTTPS.

9.1 Añadir el proxy host

Mismos pasos que la fase 8, cambiando datos:

  • Domain Names: glpi.cliente.com
  • Forward Hostname/IP: 192.168.0.231
  • Forward Port: 80
  • SSL con Force SSL + HTTP/2 (igual)
  • Cache Assets + Block Common Exploits + Websockets — todos ON.

9.2 Ajuste crítico en GLPI para funcionar detrás de proxy

⚠ Sin esto, el login de GLPI se rompe

GLPI mira $_SERVER['HTTPS'] para decidir si emitir cookies seguras. Detrás de NPM, ese valor viene vacío (el TLS termina en NPM, no en GLPI). Si activaste session.cookie_secure = 1 en la fase 10.4 del tutorial GLPI, el navegador rechaza la cookie de sesión porque cree que está en HTTP y el login falla con "Invalid session".

La solución es enseñarle a GLPI que confíe en el header X-Forwarded-Proto que NPM ya está pasando:

Ejecutar enhost: vm de GLPI (192.168.0.231)
sudo tee -a /etc/glpi/local_define.php >/dev/null <<'EOF'

// --- Trust proxy: NPM al frente ---
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
    $_SERVER['SERVER_PORT'] = 443;
}
EOF

sudo systemctl restart php8.3-fpm

9.3 Custom Nginx Configuration en NPM (opcional, headers extra)

Por defecto NPM ya pasa X-Forwarded-For, X-Forwarded-Proto, Host. Si necesitas afinar (ej. subir el client_max_body_size para que GLPI acepte adjuntos grandes en tickets), añade custom:

  1. Editar el proxy host de GLPI → tab Advanced.
  2. Pegar:
Pegar enAdvanced → Custom Nginx Configuration (UI NPM)
client_max_body_size 25M;
proxy_read_timeout   600;
proxy_send_timeout   600;
proxy_set_header X-Real-IP        $remote_addr;
proxy_set_header X-Forwarded-For  $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host             $host;
✓ Verificación

Login exitoso en https://glpi.cliente.com/ — la sesión persiste. Adjuntos grandes suben sin error 413 Request Entity Too Large. En un ticket de prueba, si GLPI muestra la IP correcta del reporter (no 192.168.0.232 del container NPM), X-Forwarded-For está bien.

10

Access Lists: capa extra de autenticación / restricción por IP

Meta (opcional): Zabbix accesible solo desde IPs corp del cliente, o con HTTP Basic Auth adicional antes del login de la app.

Access Lists son una capa previa a la aplicación. NPM valida IP o credenciales HTTP Basic y solo si pasa, deja llegar el request al backend. Útil en dos escenarios:

  • Restringir GLPI a la LAN corp del cliente + VPN — nada de acceso público aunque el DNS resuelva.
  • Doble autenticación para dashboards internos (login del jefe + login de la app).

10.1 Crear una Access List "corp-only"

  1. UI NPM → Access Lists → Add Access List.
  2. Name: corp-only.
  3. Satisfy Any: OFF (requiere pasar todas las reglas).
  4. Pass Auth to Host: OFF (no forwardear las credenciales HTTP al back-end).
  5. Tab Authorization: Allow: 192.168.0.0/24, 203.0.113.10 (IP pública corp), etc.
    (Al final: Deny all implícito).
  6. Tab Access: si quieres HTTP Basic Auth encima, añade user/password.
  7. Save.

10.2 Aplicar al Proxy Host

Editar el proxy host de GLPI → sección Access List → seleccionar corp-only. Save. Cualquier request desde afuera de esas IPs recibe 403 Forbidden antes de siquiera tocar GLPI.

💡 No aplicar Access List a Zabbix si hay agents remotos externos

Los agents Zabbix conectan al server por el puerto 10051 (no 443), así que Access List sobre HTTPS no los afecta. Pero si el cliente permite login de operadores desde 4G/casa, restringir la GUI por IP corp los deja fuera. Solo aplicar si todos los operadores están en LAN corp o VPN.

11

Backup nocturno del volumen NPM

Meta: dump comprimido de /opt/npm/data + /opt/npm/letsencrypt cada noche a las 03:00, retención 14 días.

11.1 Script

NPM entero cabe en un tar. La DB es SQLite dentro de data/, y los certs dentro de letsencrypt/. Sin nada más que respaldar. Si el cliente migró a MariaDB, añade un mariadb-dump al script.

Ejecutar enhost: vm
sudo tee /usr/local/sbin/npm-backup.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Backup nocturno de Nginx Proxy Manager — Harper.
# Los dos volúmenes bindeados en /opt/npm son toda la persistencia.
set -euo pipefail

BACKUP_DIR=/var/backups/npm
STAMP=$(date +%F_%H%M)
mkdir -p "$BACKUP_DIR"

# Detener brevemente NPM para consistencia de SQLite (segundos).
docker compose -f /opt/npm/docker-compose.yml stop app

tar -C /opt/npm -czf "$BACKUP_DIR/npm-$STAMP.tar.gz" data letsencrypt

docker compose -f /opt/npm/docker-compose.yml start app

# Retención: 14 más recientes.
ls -1t "$BACKUP_DIR"/npm-*.tar.gz | tail -n +15 | xargs -r rm --

logger -t npm-backup "backup OK · $STAMP · $(du -sh $BACKUP_DIR | cut -f1)"
EOF

sudo chmod 700 /usr/local/sbin/npm-backup.sh
sudo /usr/local/sbin/npm-backup.sh    # test manual
ls -lh /var/backups/npm/
💡 Downtime del backup

Parar NPM ~3 segundos hace que durante esos segundos ningún request llegue a Zabbix/GLPI (todo pasa por el proxy). En prod real, considera: (a) hacer el backup con NPM corriendo aceptando que SQLite puede quedar en un estado transitorio, (b) usar snapshots del filesystem (LVM, ZFS, Proxmox) que son atómicos sin parar el servicio, o (c) migrar a MariaDB y usar mariadb-dump --single-transaction.

11.2 Timer systemd

Ejecutar enhost: vm
sudo tee /etc/systemd/system/npm-backup.service >/dev/null <<'EOF'
[Unit]
Description=NPM nightly backup (data + letsencrypt)
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/npm-backup.sh
EOF

sudo tee /etc/systemd/system/npm-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Trigger npm-backup.service cada noche a las 03:00

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=5m

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now npm-backup.timer
sudo systemctl list-timers npm-backup.timer
12

Documentación de handoff al cliente

Meta: cliente recibe la info mínima para añadir nuevos servicios web al proxy sin nuestra ayuda.

12.1 Ficha de servicio

CampoValor
Cliente
Fecha de entrega
FQDN de la VM NPM
IP LAN de NPM
URL admin NPM (LAN o VPN)http://…:81/
User admin creado
Versión NPM (imagen tag)jc21/nginx-proxy-manager:2
Docker Engine versión
Backend DBSQLite (interno)
Proxy hosts configurados (lista de FQDNs)
Certs Let's Encrypt emitidos (cantidad)
Método de challenge (HTTP-01 / DNS-01 wildcard)
Proveedor DNS + credentials guardadas en
IP pública del cliente
NAT 80/443 → NPM confirmado
Ruta de backups/var/backups/npm
Off-site backup destino
Snapshots Proxmox activados (sí/no)
Access Lists creadas
Contacto técnico Harper
Notas / Excepciones

12.2 Checklist de entrega

  • VM boots limpia, Ubuntu 24.04 actualizado, UFW activo con 22/80/443/(81 LAN) abiertos.
  • SSH sin password ni root login.
  • Docker Engine + compose plugin oficiales, no docker.io del repo Ubuntu.
  • Container npm Up y healthy.
  • UI NPM accesible en http://IP:81/ (SOLO LAN).
  • Usuario default admin@example.com cambiado por uno del cliente.
  • DNS público resolviendo a la IP pública para cada FQDN a proxear.
  • NAT/port-forward en el router del cliente: 80/443 público → 192.168.0.232.
  • Al menos un cert Let's Encrypt emitido (HTTP-01 o DNS-01).
  • Al menos un Proxy Host funcionando bajo HTTPS con Force SSL + HTTP/2.
  • SSLLabs test devuelve Grade A o A+ para el FQDN de prueba.
  • GLPI (si aplica) con local_define.php ajustado para el proxy.
  • Timer npm-backup.timer activo, primer backup manual OK.
  • Snapshot Proxmox "post-handoff".
  • Ficha 12.1 rellena y compartida con el cliente.

12.3 Cheatsheet operativo

Diagnóstico y operación diaria

QuéComando
Ver estado del containerdocker compose -f /opt/npm/docker-compose.yml ps
Log en vivodocker compose -f /opt/npm/docker-compose.yml logs -f app
Reiniciar NPMdocker compose -f /opt/npm/docker-compose.yml restart app
Recrear con imagen nuevacd /opt/npm && docker compose pull && docker compose up -d
Forzar renovación de un certUI → SSL Certificates → menú (⋮) del cert → Renew
Ver la config de nginx que NPM generódocker exec npm cat /data/nginx/proxy_host/1.conf (1 = ID del proxy host)
Backup manualsudo /usr/local/sbin/npm-backup.sh
Restaurar backupExtraer el tar sobre /opt/npm/ con NPM detenido, luego docker compose up -d
Añadir un nuevo servicioUI → Proxy Hosts → Add Proxy Host → llenar dominio + IP+puerto interno + cert
Ver todos los certs y su expiraciónUI → SSL Certificates (columna "Expires")

12.4 Migración a MariaDB (cuando el cliente escala > 50 servicios)

Si el cliente crece a decenas o cientos de proxy hosts y notas la UI lenta, migra la DB de SQLite a MariaDB:

Editar/opt/npm/docker-compose.yml
services:
  app:
    image: 'jc21/nginx-proxy-manager:2'
    ...
    environment:
      DB_MYSQL_HOST: 'db'
      DB_MYSQL_PORT: 3306
      DB_MYSQL_USER: 'npm'
      DB_MYSQL_PASSWORD: 'CAMBIAR_PASSWORD_FUERTE'
      DB_MYSQL_NAME: 'npm'
      DISABLE_IPV6: 'true'
      TZ: America/Santo_Domingo
    depends_on:
      - db

  db:
    image: 'mariadb:10.11'
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: 'ROOT_PASSWORD_FUERTE'
      MARIADB_DATABASE: 'npm'
      MARIADB_USER: 'npm'
      MARIADB_PASSWORD: 'CAMBIAR_PASSWORD_FUERTE'
    volumes:
      - ./mysql:/var/lib/mysql
✓ Fin del tutorial

Con NPM al frente, añadir un nuevo servicio al stack del cliente es 3 minutos de UI: Add Proxy Host + Add SSL. Sin tocar Certbot, sin editar vhosts manualmente, sin renovaciones que se olvidan. Este es el patrón por defecto para cualquier cliente Harper con ≥ 2 servicios web.

Departamento de Infraestructura · Harper · 2026 Tutorial · Nginx Proxy Manager sobre Proxmox