n8n en un VPS: por qué lo securicé por capas en vez de levantarlo y ya
Quería practicar y usar n8n, la vía rápida era n8n Cloud pero es de pago todos los meses y resulta algo costoso para lo que iba a usarlo. La alternativa era montar n8n yo mismo, solo necesitaba dónde levantarlo. El problema es que un docker run con la instalación por defecto expone el puerto 5678 y deja el panel de control de tus automatizaciones abierto a Internet sin nada delante corriendo un riesgo que no me apetecía asumir solo por comodidad.
Así que compré un VPS barato, monté n8n y ya que tocaba exponer a Internet un servicio que guarda credenciales y habla con APIs externas lo securicé en cada capa que podía tocar.
Esto no es un tutorial de n8n, es el razonamiento detrás de cada decisión que tomé, qué riesgo cubría cada una y dónde están los límites de lo que monté.
Por qué no dejarlo simplemente público
Al principio pensé en levantarlo con HTTPS pero cuanto más miraba la superficie del panel menos sentido tenía ya que n8n guarda credenciales de servicios, tokens de API y la lógica de tus automatizaciones. Cualquiera con la URL podía ver el login.
Y el login ya es información porque confirma que ahí hay un n8n, qué versión corre, que tienes algo que merece la pena mirar.
La conclusión fue que el panel no tenía por qué ser visible para nadie que no fuera yo. A partir de ahí, todo lo demás son capas para sostener esa idea.
La lógica de las capas
No quería confiar en una sola medida, la idea es que si una falla la siguiente contiene el daño. En este despliegue el tráfico pasa por cinco filtros antes de llegar al panel:
- Firewall perimetral del proveedor:, bloquea el tráfico antes de que llegue al servidor.
- SSH endurecido, sin root, sin contraseñas, solo clave ed25519.
- Red interna Docker, los servicios aislados, sin puertos expuestos al exterior.
- Reverse proxy con IP allowlist, el panel invisible para cualquier IP que no sea la mía.
- Secretos fuera de ficheros de configuración, credenciales en Docker secrets, nunca en texto plano.
Ninguno es complicado por separado, lo que importa es que estén todos, que entienda qué hace cada uno y que no queden huecos entre ellos.

El servidor
Elegí un Hetzner CX22: 2 vCPU, 4 GB de RAM y unos 10€/mes con IVA. Para un n8n de uso personal es más que suficiente ya que n8n en reposo consume poco y Postgres con una base de datos pequeña tampoco exige mucho. El margen es amplio y si en algún momento necesito más Hetzner me permite escalar el plan sin reconstruir nada.
La ubicación la puse en Nuremberg ya que desde España la latencia es baja y me interesaba que fuera infraestructura europea.
Para el sistema operativo fui con Ubuntu 24.04 LTS ya que tiene soporte hasta 2029 y ofrece un equilibrio razonable entre estabilidad, mantenimiento y disponibilidad de paquetes. La idea era montar una infraestructura que pudiera mantenerse durante años con el menor número posible de cambios importantes y espor eso que una versión LTS tenía bastante sentido
SSH: lo primero que tocan
Cuando se expone un servidor a Internet los intentos de acceso por SSH empiezan en cuestión de minutos, cuando ví los logs había IPs probando antes de comenzar a configurar el servidor:
May 10 03:14:22 sshd[1837]: Invalid user admin from 218.92.0.113
May 10 03:14:25 sshd[1839]: Invalid user root from 218.92.0.113
May 10 03:14:31 sshd[1841]: Invalid user ubuntu from 185.224.128.39
May 10 03:15:03 sshd[1844]: Invalid user postgres from 45.142.212.100
Lo que se ve ahí es exactamente lo que esperaba ver: bots rastreando rangos de IP y probando usuarios comunes como admin, root, ubuntu, postgres , esto no es un ataque dirigido solo eses ruido automatizado constante.
El primer cambio que hice elimina los dos vectores más probados que son el login como root y autenticación por contraseña.
# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
Root es el objetivo porque existe en todos los Linux y tiene permisos totales, si entran el servidor es suyo sin necesidad de escalar nada.
Autenticación por clave ed25519
La autenticación por contraseña no me convencía ya que por muy larga que sea puede probarse por fuerza bruta de forma remota. Con autenticación por clave eso desaparece ya que la clave privada nunca sale de tu máquina y el servidor solo verifica una firma criptográfica. No hay secreto que interceptar ni que adivinar a distancia y eso por eso que preferí usar clave.
Para el algoritmo elegí ed25519 en lugar de RSA porque genera claves más cortas con el mismo nivel de seguridad, es más rápido en verificación y es el estándar actual. RSA sigue funcionando pero no le vi sentido a desplegar claves RSA nuevas salvo que necesitara compatibilidad con algo antiguo, y no era el caso.
ssh-keygen -t ed25519 -C "hetzner-n8n" -f ~/.ssh/id_ed25519_hetzner
La passphrase la añadí como segunda capa ya que si alguien se hace con el fichero de clave privada sin ella no le sirve de nada. El comando genera dos ficheros: la clave privada que se queda en mi máquina y la pública .pub que va al servidor.
Para pasarla al servidor creé un usuario no root para evitar trabajar directamente con la cuenta root , después le concedí privilegios administrativos mediante sudo para poder realizar tareas de administración, copié la clave pública a su carpeta .ssh y ajusté los permisos SSH de manera estricta para que otros usuarios no puedan leerlo:
adduser n8nadmin
usermod -aG sudo n8nadmin
mkdir -p /home/n8nadmin/.ssh
cp /root/.ssh/authorized_keys /home/n8nadmin/.ssh/
chown -R n8nadmin:n8nadmin /home/n8nadmin/.ssh
chmod 700 /home/n8nadmin/.ssh
chmod 600 /home/n8nadmin/.ssh/authorized_keys
Con el usuario listo y la clave en su sitio, deshabité root y contraseña:
# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
El detalle que me costó un rato: en Ubuntu 24.04 hay un segundo fichero que pisa la configuración principal.
# /etc/ssh/sshd_config.d/50-cloud-init.conf
PasswordAuthentication no
Si solo tocas sshd_config y dejas sshd_config.d/50-cloud-init.conf intacto la autenticación por contraseña sigue activa. Esto lo descubrí porque el cambio no surtía efecto y tuve que rastrear por qué. No es obvio si no sabes que ese fichero está ahí y es justo el tipo de cosa que crees haber cerrado y sigue abierta.
Fail2ban: bajar el ruido, no cerrar la puerta
Con autenticación por clave el vector de fuerza bruta ya está muerto porque los bots o cualquier atacante no van a entrar probando contraseñas pero siguen intentándolo provocando que se generen escrituras en disco, consuma recursos y ensucie los logs con ruido. Es por ello que decidí implementar fail2ban para banear las IPs que intentasen entrar varias veces.
[sshd]
maxretry = 3
findtime = 600
bantime = 604800
Con esta configuración tres intentos fallidos en diez minutos provoca un ban de una semana. Un usuario legítimo no falla tres veces en diez segundos pero un bot agresivo sí. El bantime lo dejé en una semana a propósito porque un valor enorme complica recuperarte si algún día te baneas a ti mismo por error que también puede pasar pero no debería.
Firewall perimetral: filtrar antes de que llegue
La diferencia entre un firewall dentro del servidor como iptables o UFW y uno externo como el del proveedor es dónde actúa. Con iptables el paquete ya llegó a la máquina y se rechaza ahí pero con el firewall del proveedor ni siquiera alcanza el servidor.
Eso tiene un efecto secundario que me interesaba ya que actúa como red de seguridad ante posibles errores debido a que si en algún momento un servicio queda escuchando en un puerto que no debería el firewall externo lo tapa igual independientemente de lo que pase dentro del VPS.
Las reglas de entrada son tres: TCP 22 para SSH, TCP 80 para la renovación de certificados y TCP 443 para el acceso HTTPS. Todo lo demás bloqueado por defecto.
El 80 lo necesito aunque solo acceda por HTTPS ya que el Caddy lo usa para renovar el certificado con Let’s Encrypt. Sin él, los certificados dejan de renovarse.

El tráfico saliente lo dejé completamente abierto porque n8n necesita salir a APIs externas y Caddy a Let’s Encrypt.
Docker: los servicios no se hablan por defecto
Para instalar Docker usé el script oficial que detecta la distribución automáticamente:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker n8nadmin
El usuario n8nadmin lo añadí al grupo Docker para poder trabajar con Docker Compose sin utilizar sudo en cada operación. No deja de ser una decisión basada en confianza ya que quien controla Docker tiene un nivel de acceso muy elevado en el sistema. En este caso al tratarse de un servidor administrado únicamente por mí preferí priorizar la operatividad.
n8n, Postgres y Caddy viven en una red interna de Docker. Postgres no publica ningún puerto y a la base de datos no se llega desde fuera del stack, solo n8n habla con ella por nombre de servicio interno. El 5678 de n8n tampoco está expuesto. Desde fuera ese puerto no existe.
Solo Caddy ve el exterior. Todo lo demás está detrás.
Secretos fuera de las variables de entorno
Si las credenciales van en el compose.yaml como variables de entorno puede ocurrir que ese fichero acaba donde no debe: en Git, en un log, en una captura que compartes sin pensar. Con Docker secrets las credenciales viven en ficheros con permisos restringidos y dentro del contenedor aparecen en /run/secrets/ , nunca en las variables de entorno que cualquier proceso del contenedor puede leer.
mkdir -p ~/n8n/secrets
openssl rand -base64 32 > ~/n8n/secrets/pg_password.txt
openssl rand -base64 32 > ~/n8n/secrets/n8n_encryption_key.txt
chmod 600 ~/n8n/secrets/*.txt
La N8N_ENCRYPTION_KEY merece atención aparte ya que n8n cifra con ella todas las credenciales que guardas en los workflows. Si la clave cambia al recrear el contenedor, todas esas credenciales quedan ilegibles de golpe. La trato como clave maestra donde no debe de estar en el compose, en Git, y no se regenera salvo en un proceso controlado y a sabiendas de lo que implica.
compose.yaml
Así sería el docker compose con tres servicios en red interna. Ningún puerto de aplicación publicado hacia el exterior salvo los de Caddy:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: n8n
POSTGRES_USER: n8n
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
secrets:
- pg_password
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- internal
restart: unless-stopped
n8n:
image: docker.n8n.io/n8nio/n8n:stable
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD_FILE: /run/secrets/pg_password
N8N_ENCRYPTION_KEY_FILE: /run/secrets/n8n_encryption_key
N8N_HOST: <SUBDOMINIO>
N8N_PROTOCOL: https
WEBHOOK_URL: https://<SUBDOMINIO>/
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "168"
GENERIC_TIMEZONE: Europe/Madrid
secrets:
- pg_password
- n8n_encryption_key
volumes:
- n8n_data:/home/node/.n8n
networks:
- internal
depends_on:
- postgres
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- internal
restart: unless-stopped
networks:
internal:
volumes:
pgdata:
n8n_data:
caddy_data:
caddy_config:
secrets:
pg_password:
file: ./secrets/pg_password.txt
n8n_encryption_key:
file: ./secrets/n8n_encryption_key.txt
Algunas de las decisiones que tomé de este YAML:
Postgres en lugar de SQLite. n8n usa SQLite por defecto y es lo más rápido de levantar, pero SQLite no maneja bien la concurrencia y hace backup sucio si hay escrituras en curso. Con workflows ejecutándose con frecuencia decidí que Postgres es la decisión correcta desde el principio.
stable en lugar de latest. n8n distingue explícitamente entre los dos tags. latest puede apuntar a versiones beta y en un despliegue que quiero mantener funcionando sin problemas no tenía sentido asumir ese riesgo cuando existe un tag específico para producción.
El puerto 5678 no está publicado. Si lo publicara en el compose cualquiera que alcanzara el servidor llegaría al panel directamente sin pasar por Caddy, sin TLS, sin control de acceso. Al no publicarlo ese puerto no existe fuera de la red interna Docker. Caddy es el único que puede llegar a n8n y lo hace por nombre de servicio. Eso reduce la superficie a un solo punto de entrada.
EXECUTIONS_DATA_MAX_AGE: “168”. Retención de 7 días. Sin esto el historial de ejecuciones crece indefinidamente. Es fácil no darse cuenta hasta que el disco está al 90% y empiezan los problemas y es por ello que preferí ponerlo desde el primer día.
GENERIC_TIMEZONE: Europe/Madrid. Los timestamps en UTC dificultan correlacionar eventos con lo que realmente pasó. Con la hora local los logs tienen sentido a primera vista sin tener que hacer conversiones mentales.
Caddy: el único punto de entrada visible
Caddy hace de reverse proxy el cual gestiona el TLS automáticamente con Let’s Encrypt y es donde implemento el control de acceso real.
IP allowlist: el panel invisible
Cualquier IP que no sea la mía recibe un 403 Forbidden y no ve nada más, ni el login de n8n, ni una pista de qué hay detrás. Para todo lo que no sea mi IP el panel sencillamente no está ahí.

La pega es que si mi IP cambia ,otra conexión, un viaje o una IP dinámica que rota provoca que pierda el acceso hasta que actualice la config y recargue Caddy. Es incómodo. Pero la alternativa es dejar el panel expuesto al mundo, y entre incómodo y expuesto me quedo con incómodo. Una alternativa sería usar DDNS con mi IP publica.
Rutas separadas para webhooks
Los webhooks sí tienen que ser accesibles desde Internet porque los invocan servicios externos. Van en rutas separadas que saltan el allowlist, mientras el resto sigue protegido:
handle /webhook/* {
reverse_proxy n8n:5678
}
handle /webhook-test/* {
reverse_proxy n8n:5678
}
handle {
@tuip remote_ip <TU_IP>
handle @tuip {
reverse_proxy n8n:5678
}
handle {
respond "403 Forbidden" 403
}
}
Cabeceras de seguridad
Estas son las cabeceras de seguridad que utilicé
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
-Server
Strict-Transport-Security fuerza al navegador a usar siempre HTTPS y una vez que lo recibe no vuelve a intentar HTTP durante un año. Si alguien intenta hacer un downgrade de conexión, el navegador lo rechaza directamente.
X-Content-Type-Options nosniff evita que el navegador adivine el tipo de contenido de un archivo y lo interprete como algo que no es lo que cierra un vector clásico de inyección.
X-Frame-Options DENY impide que el panel se cargue dentro de un iframe ajeno cortando ataques de clickjacking.
Referrer-Policy no-referrer hace que el navegador no envíe información de origen cuando navegas fuera del panel ya que nadie necesita saber desde dónde llegaste. Y quitar el header Server elimina la pista de qué software está detrás, un detalle pequeño, pero reduce el reconocimiento pasivo y no cuesta nada hacerlo.
Caddyfile completo
Para referencia, este es el Caddyfile completo con todo lo anterior integrado:
<SUBDOMINIO> {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
-Server
}
handle /webhook/* {
reverse_proxy n8n:5678
}
handle /webhook-test/* {
reverse_proxy n8n:5678
}
handle {
@tuip remote_ip <IP NUESTRA>
handle @tuip {
reverse_proxy n8n:5678
}
handle {
respond "403 Forbidden" 403
}
}
}
Para recargar Caddy sin reiniciar el contenedor usé el siguiente comando:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
DNS y TLS
Para el DNS añadí un registro A apuntando el subdominio a la IP del VPS. Puse TTL 300 durante la configuración para que propagara rápido.
Caddy resuelve el challenge TLS-ALPN-01 con Let’s Encrypt automáticamente al arrancar. No hay ningún cron, ningún certbot, ningún proceso externo que gestionar. Los logs confirman el momento exacto en que el certificado está listo:
caddy | {"level":"info","msg":"certificate obtained successfully","identifier":"<SUBDOMINIO>"}
A partir de ahí la renovación es automática y transparente.
Arranque y lo que encontré la primera vez
docker compose up -d
docker compose ps
El orden en el que verifico es el siguiente: primero que los tres contenedores estén en Up, luego los logs de Caddy para confirmar el certificado, luego acceso al panel desde mi IP y 403 desde otra IP diferente.
Algo que me encontré en el primer arranque fue que el depends_on del compose garantiza que Postgres arranca antes que n8n, pero no espera a que Postgres esté listo para aceptar conexiones. n8n puede arrancar antes de que la base de datos haya terminado de inicializarse y falla en el primer intento de conexión provocando que en los logs se muestre un error de conexión que pueda confundir si no sabe qué es. Con restart: unless-stopped Docker lo reintenta automáticamente y en unos segundos se resuelve solo, pero la primera vez que lo vi tuve que investigar si era un problema real o simplemente un tema de timing. Era lo segundo.
Lo que no sube a Git
El compose.yaml y el Caddyfile van a un repositorio privado. La carpeta secrets/ nunca aunque el repositorio sea privado, los secretos en Git siguen siendo un riesgo ya que con que se haga público por error, que un token de acceso se filtre, o que alguien con acceso de lectura no debiera tenerlo.
Los secretos viven solo en el servidor, con permisos 600, propiedad del usuario que corre los contenedores. Es el único sitio donde tienen que estar.
Lo que falta
Hay dos cosas que dejé pendientes y prefiero decirlas claras antes de que alguien las encuentre por mí.
Los endpoints /webhook/* están públicos por diseño pero ahora mismo no tienen rate limiting y esto provoca que cualquiera pueda mandarles peticiones en masa. Resolverlo bien requiere una imagen propia de Caddy con el plugin caddy-ratelimit, así que de momento queda apuntado.
La autenticación de webhooks con HMAC tampoco está. Hoy quien conozca la URL de un webhook puede invocarlo sin más. La solución es validar un header firmado dentro de cada workflow antes de ejecutar la lógica. Lo dejaré documentado en un post aparte cuando tenga flujos reales que enseñar, no antes.
Lo que queda montado
El servidor solo acepta SSH por clave, sin root posible. El firewall perimetral bloquea todo lo que no sean los tres puertos que necesito. Los servicios están aislados en red interna y ningún puerto de aplicación se expone directamente. El panel de n8n es invisible para cualquier IP que no sea la mía y las credenciales no aparecen en texto plano en ningún fichero de configuración.
No es 100% seguro ya que eso no existe. Es una configuración donde cada decisión tiene un motivo detrás y las limitaciones están identificadas antes de convertirse en un problema. Para lo que necesitaba practicar n8n sin pagar cloud y sin dejar un panel sensible abierto al mundo me parece el equilibrio correcto.