Cómo blindar tus servidores de Coolify con Tailscale: guía paso a paso
Cómo saqué el SSH y el panel de Coolify de internet y los llevé a una red privada de Tailscale, cerré todos los puertos menos el 80 y el 443, y lo comprobé desde dentro y desde fuera de cada servidor. Todos los pasos y comandos, y dos scripts de auditoría que puedes usar en tus propios servidores.
English version: Locking Down Coolify Servers With Tailscale: A Step-by-Step Guide
Tengo varios VPS gestionados por una instancia de Coolify que alojo yo mismo. Coolify es genial: despliega mis apps, se encarga de los certificados TLS y gestiona cada servidor por SSH. Pero cuando me puse a mirar qué exponían mis servidores a internet, no me gustó lo que vi: el SSH estaba abierto al mundo en todas las máquinas, el panel de Coolify se podía abrir desde una IP y un puerto públicos, y uno de los servidores ni siquiera tenía el firewall activado.
Este post es el proceso que seguí para arreglarlo, escrito para que puedas seguirlo paso a paso. La idea es sencilla: solo tus apps deberían ser públicas. Todo lo que usas para gestionar los servidores (el SSH y el panel de Coolify) solo debería ser accesible desde una red privada que controles tú. Para esa red privada usé Tailscale.
El objetivo
Antes:
- SSH (puerto 22) abierto a internet en todos los servidores.
- El panel de Coolify (puerto 8000) y sus puertos de tiempo real (6001 y 6002) abiertos a internet.
- Inicio de sesión por SSH con contraseña permitido.
Después:
- Desde internet solo se llega a los puertos 80 y 443 (tus apps).
- El SSH y el panel de Coolify solo funcionan a través de Tailscale.
- El SSH solo acepta claves, nunca contraseñas.
- Coolify sigue gestionando todos los servidores y los despliegues siguen funcionando.
Los ejemplos usan datos inventados. Cámbialos por los tuyos:
control: el servidor donde corre Coolify. IP pública203.0.113.10, IP de Tailscale100.64.0.10.apps-1: un servidor gestionado por Coolify. IP pública203.0.113.20, IP de Tailscale100.64.0.20.deploy: tu usuario normal, consudo.
1. Mete todos los servidores en tu tailnet
Tailscale crea una red privada y cifrada (una "tailnet") entre tus dispositivos. Instálalo en tu portátil y en cada servidor, incluido el que ejecuta Coolify:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --hostname=apps-1 --accept-dns=false
tailscale ip -4 # muestra la dirección 100.x.y.z del servidorAlgunas notas:
--hostnamees solo el nombre que tendrá la máquina dentro de tu tailnet. No tiene por qué coincidir con el nombre real del servidor. Con MagicDNS activado, puedes llegar a ella comoapps-1oapps-1.<tu-tailnet>.ts.net.--accept-dns=falsemantiene el DNS normal del servidor. Los contenedores de Docker dependen de él, así que no dejes que Tailscale lo sustituya.- En la consola de administración de Tailscale, desactiva la caducidad de la clave (key expiry) en cada servidor. Si no, dentro de unos meses la clave caduca, el servidor se cae de la tailnet y, con el SSH público cerrado, te quedas fuera.
Comprueba que el servidor de Coolify llega al nuevo servidor por la red privada:
# en control
tailscale ping 100.64.0.202. Conecta Coolify a tus servidores a través de Tailscale
Coolify se comunica con cada servidor por SSH, así que necesita una clave en la que el servidor confíe.
En Coolify, ve a Keys & Tokens → Private Keys → New private key → Generate ED25519 (sin passphrase) y copia la clave pública. Después, en el servidor:
sudo mkdir -p /root/.ssh && sudo chmod 700 /root/.ssh
echo "<clave pública de coolify>" | sudo tee -a /root/.ssh/authorized_keys
sudo chmod 600 /root/.ssh/authorized_keysAsegúrate de que la clave va a /root/.ssh/authorized_keys si Coolify se conecta como root. Si la añades estando conectado con otro usuario, acaba en el fichero de ese usuario.
Ahora ve a Servers → + Add y usa como dirección la IP de Tailscale del servidor (100.64.0.20), puerto 22, usuario root y la clave que acabas de crear. Pulsa Validate Server.
Si el servidor ya estaba en Coolify con su IP pública, cambia la IP por la de Tailscale y vuelve a validar. Hazlo antes de cerrar ningún puerto, o Coolify perderá el acceso.
Dos cosas con las que me tropecé:
- Usa la IP, no el nombre de MagicDNS. Coolify corre dentro de un contenedor de Docker y, con
--accept-dns=false, no puede resolver los nombres*.ts.net. - Tailscale SSH. Si arrancaste Tailscale con
--ssh, el puerto 22 de la IP de la tailnet lo atiende Tailscale, no OpenSSH, y se ignoraauthorized_keys. O lo mantienes y añades una regla SSH en tu tailnet con"action": "accept"para el servidor de Coolify, o lo desactivas consudo tailscale set --ssh=falsey dejas que OpenSSH use la clave de Coolify. Una regla"check"no sirve, porque pide iniciar sesión en el navegador y Coolify no puede hacerlo.
Si la validación falla, estos comandos en el servidor de Coolify te dicen dónde se rompe:
tailscale ping 100.64.0.20 # ¿llega la tailnet?
nc -vz -w 5 100.64.0.20 22 # ¿está abierto el puerto 22 por la tailnet?
nc -w 3 100.64.0.20 22 </dev/null | head -1 # ¿SSH-2.0-OpenSSH... o SSH-2.0-Tailscale?
sudo docker exec coolify ls /var/www/html/storage/app/ssh/keys/
sudo docker exec coolify ssh -i /var/www/html/storage/app/ssh/keys/<fichero-de-clave> \
-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@100.64.0.20 echo OKSi el último comando imprime OK, la clave de Coolify funciona y puedes volver a validar. Permission denied (publickey) significa que la clave no está en el authorized_keys de root, o que la configuración de SSH la rechaza.
3. Activa el firewall
En cada servidor gestionado, permite todo lo que llega por Tailscale, permite el tráfico web y bloquea todo lo demás:
sudo ufw allow in on tailscale0
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable
sudo ufw status verboseHazlo desde una sesión SSH por Tailscale (ssh deploy@apps-1), no por la IP pública. Cuando ufw enable avise de que puede cortar las conexiones SSH, tu sesión sobrevive porque el tráfico por tailscale0 está permitido.
El servidor donde corre Coolify necesita más cuidado. Coolify también se conecta por SSH a su propio servidor desde una red de Docker, y un UFW que lo bloquea todo por defecto corta esa conexión. Si activas UFW ahí, permite también el SSH desde las redes de Docker:
sudo ufw allow from 10.0.0.0/8 to any port 22 proto tcp
sudo ufw allow from 172.16.0.0/12 to any port 22 proto tcpEn mi servidor de Coolify no usé UFW y me apoyé solo en el firewall del proveedor del paso 5.
4. Solo claves: desactiva el inicio de sesión SSH con contraseña
Aunque el puerto 22 ya esté cerrado a internet, el inicio de sesión con contraseña debería estar desactivado. Crea un pequeño fichero de configuración:
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin prohibit-password\n' \
| sudo tee /etc/ssh/sshd_config.d/00-hardening.conf
sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -E '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)'- El nombre del fichero empieza por
00-a propósito. En Ubuntu, los ficheros desshd_config.dse leen en orden y gana el primer valor, y50-cloud-init.confsuele volver a activar las contraseñas. PermitRootLogin prohibit-passwordpermite a root entrar solo con clave, que es lo que necesita Coolify. Si en algún servidor el login de root ya está enno(por ejemplo, porque ahí Coolify usa Tailscale SSH), quita esa línea.- No cierres tu sesión actual: prueba a conectarte desde otra terminal antes de cerrarla.
La contraseña de tu usuario sigue haciendo falta para sudo. Si necesitas cambiarla, entra como root y ejecuta passwd deploy.
5. La trampa de Docker: los puertos publicados se saltan UFW
Este es el paso que casi todas las guías se saltan. Cuando un contenedor de Docker publica un puerto (como 8000:8080), Docker escribe sus propias reglas de firewall, que se saltan UFW. Después del paso 3, ufw status decía "deny incoming" en mi servidor de Coolify, pero el panel en el puerto 8000 y los puertos de tiempo real 6001 y 6002 seguían abiertos a internet.
La solución más limpia es el firewall de tu proveedor cloud, que filtra el tráfico antes de que llegue al servidor, así que Docker no puede saltárselo. La mayoría de proveedores lo ofrecen gratis (Hetzner, DigitalOcean, Contabo y otros). Crea uno que solo permita:
- TCP 80 y 443: tus apps.
- UDP 443: HTTP/3 (opcional).
- UDP 41641: conexiones directas de Tailscale. Sin esta regla, Tailscale sigue funcionando, pero a través de relays más lentos.
Todo lo demás se descarta.
Antes de asignarlo:
- Abre el panel de Coolify por Tailscale en lugar de por la IP pública:
http://100.64.0.10:8000. Aquí HTTP sin más está bien, porque Tailscale ya cifra el tráfico. - Comprueba que puedes entrar por SSH a través de Tailscale.
Y después asigna el firewall al servidor. Mi primer intento no hizo nada: había creado el firewall con todas las reglas correctas, pero estaba asignado a cero servidores.
Una vez asignado, comprueba desde el servidor que el tráfico de salida sigue funcionando:
tailscale status # los demás equipos deberían seguir en "active; direct"
curl -I https://github.comSi algún servidor ejecuta algo que tiene que ser público en otro puerto (por ejemplo, un daemon peer-to-peer), añade una regla para ese puerto o usa un firewall distinto para ese servidor.
6. Compruébalo desde dentro y desde fuera
Una configuración de firewall es una promesa. Lo que quieres son pruebas. Escribí dos scripts (tienes el código completo al final del post):
server-audit.shse ejecuta en cada servidor y revisa Tailscale, UFW, los puertos publicados por Docker, la configuración de SSH, las claves autorizadas, los usuarios con sudo y las actualizaciones automáticas. En el servidor de Coolify, además, comprueba que Coolify sigue pudiendo entrar por SSH a cada servidor que gestiona. No cambia nada.external-check.shse ejecuta desde tu portátil y prueba las IPs públicas, para ver lo que realmente ve internet.
Ejecuta la auditoría en todos los servidores a través de Tailscale:
for h in deploy@control deploy@apps-1; do
echo "########## $h"
scp server-audit.sh "$h":/tmp/ && ssh -t "$h" 'sudo bash /tmp/server-audit.sh'
doneY después compruébalo desde fuera, tanto por IPv4 como por IPv6 (la auditoría muestra las direcciones públicas de cada servidor):
PORTS="22 80 443 6001 6002 8000 8080" bash external-check.sh 203.0.113.10 203.0.113.20
PORTS="22 80 443 6001 6002 8000 8080" bash external-check.sh 2001:db8:10::1 2001:db8:20::1Lo que quieres ver en cada servidor:
== 203.0.113.10 ==
22 closed
80 open (expected)
443 open (expected)
6001 closed
6002 closed
8000 closed
8080 closedNo te saltes IPv6. La mayoría de VPS también tienen una dirección IPv6 pública, y un firewall que solo cubre IPv4 deja la puerta medio abierta.
Una nota sobre el puerto 8080: el proxy Traefik de Coolify lo publica, así que la auditoría avisa. En mis servidores no había nada escuchando ahí y la comprobación externa lo mostraba cerrado. Fíate de la comprobación externa.
7. No rompas tus despliegues
Si tus apps se redespliegan solas cuando haces push a GitHub, GitHub envía un webhook a Coolify. Si Coolify se configuró sin dominio, ese webhook va a http://<ip-pública>:8000, que ahora está cerrado.
Pruébalo: haz push de un cambio pequeño y mira la pestaña Deployments de la app en Coolify. Si no arranca nada, revisa en la GitHub App Advanced → Recent Deliveries si hay webhooks fallidos. Entonces puedes:
- darle a Coolify su propio dominio (Settings → General → Domain), para que se sirva a través del proxy en el 443, y activar el 2FA, porque la página de login pasa a ser pública; o
- mantener Coolify privado y lanzar los despliegues desde CI con la API de despliegue de Coolify, desde un runner de CI que se una a tu tailnet; o
- simplemente pulsar Redeploy cuando hagas push, si despliegas poco.
A mí me siguió funcionando, pero es justo el tipo de cosa que se rompe sin avisar.
8. Guárdate una forma de volver a entrar
Con el SSH público cerrado, Tailscale es tu única puerta de entrada. Prepárate para el día en que falle:
- Conoce la consola de tu proveedor. Todos los proveedores de VPS tienen una consola web (VNC). Usa un login local con contraseña, no SSH, así que asegúrate de conocer una contraseña que funcione para root o para tu usuario con
sudoen cada servidor, y guárdala en tu gestor de contraseñas. - Caducidad de la clave desactivada en todos los servidores, desde la consola de Tailscale.
- Haz copias de seguridad de Coolify. En Coolify, Settings → Backup puede enviar copias programadas a un almacenamiento compatible con S3. Guarda también el
APP_KEYde/data/coolify/source/.env. Sin él no se puede restaurar una copia. - Activa el 2FA en Coolify.
- Limpia tu tailnet. Cualquier dispositivo de tu tailnet puede llegar a cualquier puerto de tus servidores. Elimina móviles y portátiles antiguos, y plantéate usar reglas de acceso para que solo tu portátil y tus servidores puedan llegar a ellos.
Una cosa más: si algún día SSH te avisa de que la clave de host de un servidor ha cambiado, no borres la entrada antigua sin más. Entra por otra vía (por ejemplo, la consola del proveedor) y compara antes la huella:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubSi coincide con la que te mostró SSH, borra la entrada antigua con ssh-keygen -R <host>.
Lista final
- Todos los servidores están en la tailnet, con la caducidad de la clave desactivada.
- Coolify se conecta a cada servidor por su IP
100.xde Tailscale. - UFW está activo en los servidores gestionados: tailnet permitida, 80/443 permitidos y todo lo demás bloqueado.
- El SSH solo acepta claves.
- El firewall del proveedor está asignado y solo permite 80/443 (y UDP 41641 para Tailscale).
- La comprobación externa solo muestra abiertos el 80 y el 443, por IPv4 y por IPv6.
- Un push a GitHub sigue desplegando.
- Sabes cómo entrar por la consola del proveedor y Coolify tiene copias de seguridad.
Vuelve a ejecutar los dos scripts cada vez que añadas un servidor o despliegues algo que abra un puerto.
Anexo: los scripts
server-audit.sh
Ejecútalo en cada servidor con sudo bash server-audit.sh. Es de solo lectura: muestra PASS, WARN o FAIL, con la solución para la mayoría de los problemas. Los mensajes del script están en inglés.
#!/usr/bin/env bash
# Read-only security audit for a Coolify + Tailscale server.
# Usage: sudo bash server-audit.sh
# Optional: ALLOWED_PUBLIC_PORTS="80 443" sudo -E bash server-audit.sh
# Changes nothing. Exit code 1 if any FAIL.
set -u
((BASH_VERSINFO[0] >= 4)) || { echo "Needs bash 4+"; exit 2; }
ALLOWED_PUBLIC_PORTS="${ALLOWED_PUBLIC_PORTS:-80 443}"
if [ -t 1 ]; then R=$'\e[31m' Y=$'\e[33m' G=$'\e[32m' B=$'\e[1m' N=$'\e[0m'; else R='' Y='' G='' B='' N=''; fi
fails=0 warns=0
pass() { printf '%s[PASS]%s %s\n' "$G" "$N" "$*"; }
warn() { printf '%s[WARN]%s %s\n' "$Y" "$N" "$*"; warns=$((warns + 1)); }
fail() { printf '%s[FAIL]%s %s\n' "$R" "$N" "$*"; fails=$((fails + 1)); }
info() { printf ' %s\n' "$*"; }
section() { printf '\n%s== %s ==%s\n' "$B" "$*" "$N"; }
have() { command -v "$1" >/dev/null 2>&1; }
allowed() { case " $ALLOWED_PUBLIC_PORTS " in *" $1 "*) return 0 ;; esac; return 1; }
is_public_addr() {
case "$1" in
127.* | ::1 | "[::1]" | 100.* | "[fd7a:115c:a1e0:"* | fd7a:115c:a1e0:* | 10.* | 192.168.* | 172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) return 1 ;;
esac
return 0
}
[ "$(id -u)" -eq 0 ] || { echo "Run as root: sudo bash $0"; exit 2; }
ufw_active=0
docker_ok=0
have docker && docker info >/dev/null 2>&1 && docker_ok=1
section "Host"
info "hostname: $(hostname) os: $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}")"
info "public addresses (use these with external-check.sh):"
ip -o addr show scope global 2>/dev/null |
awk '$2 !~ /^(tailscale|docker|br-|veth|lo)/ {split($4, a, "/"); print " " $2 " " a[1]}'
section "Tailscale"
if ! have tailscale; then
fail "tailscale is not installed"
else
ts_state='' ts_ip='' ts_name='' ts_expiry='' ts_tags=''
if have python3; then
{ read -r ts_state; read -r ts_ip; read -r ts_name; read -r ts_expiry; read -r ts_tags; } < <(
tailscale status --json 2>/dev/null | python3 -c '
import json, sys
d = json.load(sys.stdin); s = d.get("Self") or {}
print(d.get("BackendState", ""))
print((s.get("TailscaleIPs") or [""])[0])
print(s.get("DNSName", "").rstrip("."))
print(s.get("KeyExpiry") or "")
print(",".join(s.get("Tags") or []))' 2>/dev/null
)
else
ts_ip=$(tailscale ip -4 2>/dev/null | head -1)
tailscale status >/dev/null 2>&1 && ts_state=Running
fi
if [ "$ts_state" = Running ]; then pass "tailscale is running: ${ts_name:-?} ($ts_ip)"; else fail "tailscale is not running (state: ${ts_state:-unknown}). Run: sudo tailscale up"; fi
[ -n "$ts_tags" ] && info "tags: $ts_tags"
if [ -n "$ts_expiry" ]; then
warn "tailscale node key expires $ts_expiry. Disable key expiry in the admin console (Machines > ... > Disable key expiry) or this box drops off the tailnet."
else
pass "tailscale key expiry is disabled"
fi
if tailscale debug prefs 2>/dev/null | grep -q '"RunSSH": *true'; then
info "Tailscale SSH is enabled: port 22 on the tailnet IP is answered by Tailscale, not OpenSSH, and authorized_keys is ignored."
info "Coolify then needs a tailnet SSH rule with action \"accept\". Only disable it (sudo tailscale set --ssh=false) if OpenSSH accepts Coolify's key."
fi
fi
section "Firewall (UFW)"
if ! have ufw; then
fail "ufw is not installed (apt install ufw)"
else
ufw_v=$(ufw status verbose 2>/dev/null)
if grep -q '^Status: active' <<<"$ufw_v"; then pass "ufw is active"; ufw_active=1; else fail "ufw is inactive"; fi
if [ "$ufw_active" = 1 ]; then
if grep -Eq 'Default: (deny|reject) \(incoming\)' <<<"$ufw_v"; then pass "default incoming policy is deny"; else fail "default incoming policy is not deny (sudo ufw default deny incoming)"; fi
if grep -Eq '^IPV6=yes' /etc/default/ufw 2>/dev/null; then pass "ufw also filters IPv6"; else warn "ufw IPv6 filtering is off (/etc/default/ufw IPV6=no) and this server may have a public IPv6"; fi
ts_rule=0
declare -A seen_rule=()
while IFS= read -r line; do
line="${line%%#*}"
[[ $line =~ ^(.*[^[:space:]])[[:space:]]+(ALLOW|LIMIT)(\ (IN|FWD))?[[:space:]]+(.*[^[:space:]])[[:space:]]*$ ]] || continue
to="${BASH_REMATCH[1]}" from="${BASH_REMATCH[5]}"
to="${to// (v6)/}" from="${from// (v6)/}"
iface=''
if [[ $to == *" on "* ]]; then iface="${to##* on }"; to="${to%% on *}"; fi
if [ "$iface" = tailscale0 ]; then ts_rule=1; continue; fi
[ -n "${seen_rule[$to|$from|$iface]:-}" ] && continue
seen_rule[$to|$from|$iface]=1
if [ "$from" != Anywhere ]; then info "rule: allow $to from $from${iface:+ on $iface}"; continue; fi
case "$to" in
Anywhere) fail "ufw allows ALL ports from anywhere${iface:+ on $iface}" ; continue ;;
OpenSSH) fail "ufw allows SSH (OpenSSH profile) from anywhere. Remove it: sudo ufw delete allow OpenSSH" ; continue ;;
"Nginx Full" | "Apache Full" | "WWW Full") continue ;;
esac
ports="${to%%/*}"
ports="${ports##* }"
if ! [[ $ports =~ ^[0-9,:]+$ ]]; then warn "ufw allows '$to' from anywhere (app profile, check what it opens)"; continue; fi
IFS=',' read -ra toks <<<"$ports"
for t in "${toks[@]}"; do
lo="${t%%:*}" hi="${t##*:}"
if [ "$lo" -le 22 ] && [ "$hi" -ge 22 ]; then
fail "ufw allows SSH (port $t) from anywhere. Remove it: sudo ufw delete allow $to"
elif [ "$lo" = "$hi" ] && allowed "$lo"; then
:
else
warn "ufw allows port $t from anywhere. Make sure that is intended."
fi
done
done < <(ufw status 2>/dev/null)
if [ "$ts_rule" = 1 ]; then pass "ufw allows tailnet traffic on tailscale0"; else warn "no 'allow in on tailscale0' rule, so tailnet SSH may be blocked (sudo ufw allow in on tailscale0)"; fi
fi
fi
section "Host services listening on all interfaces"
if have ss; then
found=0
while read -r proto addr proc; do
port="${addr##*:}" host="${addr%:*}"
case "$proc" in *docker-proxy* | *tailscaled*) continue ;; esac
case "$host" in 0.0.0.0 | "[::]" | "*" | "[::]%"* | "0.0.0.0%"*) ;; *) is_public_addr "${host%%%*}" || continue ;; esac
allowed "$port" && continue
found=1
pname=$(sed -E 's/.*\(\("([^"]+)".*/\1/' <<<"$proc")
if [ "$ufw_active" = 1 ]; then info "$proto $addr ($pname). Blocked by UFW unless a rule above allows it."; else warn "$proto $addr ($pname) is exposed because ufw is inactive"; fi
done < <(ss -H -tulpn 2>/dev/null | awk '{print $1, $5, $7}')
[ "$found" = 0 ] && pass "no unexpected host services on public interfaces"
fi
section "Docker published ports (these bypass UFW)"
if [ "$docker_ok" != 1 ]; then
info "docker is not running or not installed, skipping"
else
docker_user_rules=$(iptables -S DOCKER-USER 2>/dev/null | grep -v -e '^-N DOCKER-USER' -e '^-A DOCKER-USER -j RETURN$')
[ -n "$docker_user_rules" ] && info "DOCKER-USER chain has custom rules, so some ports may be filtered. Confirm with external-check.sh."
exposed=0
declare -A seen_port=()
while IFS=$'\t' read -r name ports; do
IFS=',' read -ra items <<<"$ports"
for it in "${items[@]}"; do
it="${it# }"
[[ $it == *"->"* ]] || continue
hostpart="${it%%->*}" proto="${it##*/}"
hport="${hostpart##*:}" hip="${hostpart%:*}"
case "$hip" in 0.0.0.0 | "[::]" | :: | "") ;; *) is_public_addr "$hip" || continue ;; esac
allowed "$hport" && continue
key="$name|$hport|$proto"
[ -n "${seen_port[$key]:-}" ] && continue
seen_port[$key]=1
exposed=1
case "$name:$hport" in
coolify:8000) msg="Coolify dashboard" ;;
coolify:6001 | coolify:6002) msg="Coolify realtime" ;;
coolify-proxy:8080) msg="Traefik dashboard/API port" ;;
*) msg="" ;;
esac
if [ "$name:$hport" = coolify-proxy:8080 ]; then
warn "container '$name' publishes $hport/$proto on $hip ($msg). Traefik usually does not listen there; confirm it shows closed with external-check.sh."
elif [ -n "$docker_user_rules" ]; then
warn "container '$name' publishes $hport/$proto on $hip${msg:+ ($msg)}. Possibly filtered by DOCKER-USER; verify externally."
else
fail "container '$name' publishes $hport/$proto on $hip${msg:+ ($msg)}. It is reachable from the internet even though UFW denies it."
fi
done
done < <(docker ps --format '{{.Names}}\t{{.Ports}}')
if [ "$exposed" = 0 ]; then
pass "no containers publish ports other than: $ALLOWED_PUBLIC_PORTS"
else
info "Fix: remove the port mapping (route the app through the Coolify proxy with a domain),"
info "or block the port in your provider's firewall or the DOCKER-USER iptables chain."
fi
fi
if [ "$docker_ok" = 1 ] && docker ps --format '{{.Names}}' | grep -qx coolify; then
section "Coolify control plane: SSH reachability to managed servers"
envf=/data/coolify/source/.env
dbu=$(sed -n 's/^DB_USERNAME=//p' "$envf" 2>/dev/null | head -1)
dbn=$(sed -n 's/^DB_DATABASE=//p' "$envf" 2>/dev/null | head -1)
rows=$(docker exec coolify-db psql -U "${dbu:-coolify}" -d "${dbn:-coolify}" -tA -F'|' \
-c 'select name, ip, port, "user" from servers order by id' 2>/dev/null)
if [ -z "$rows" ]; then
info "could not read the server list from coolify-db, skipping"
else
while IFS='|' read -r sname sip sport suser; do
[ -n "$sip" ] || continue
out=$(docker exec coolify ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p "$sport" "$suser@$sip" true 2>&1)
case "$out" in
"" | *"Permission denied"*) pass "Coolify reaches '$sname' at $suser@$sip:$sport" ;;
*) fail "Coolify cannot reach '$sname' at $suser@$sip:$sport: $(tail -1 <<<"$out")" ;;
esac
case "$sip" in
100.* | host.docker.internal | localhost | 127.* | *.ts.net) ;;
*) warn "'$sname' uses $sip, which is not a Tailscale address, so SSH to it must be publicly reachable. Switch it to its 100.x IP." ;;
esac
done <<<"$rows"
fi
fi
section "SSH daemon"
if have sshd; then
cfg=$(sshd -T 2>/dev/null)
sget() { awk -v k="$1" '$1 == k {print $2; exit}' <<<"$cfg"; }
case "$(sget passwordauthentication)" in
no) pass "password authentication is disabled" ;;
*) fail "password authentication is enabled. Set 'PasswordAuthentication no' in /etc/ssh/sshd_config(.d), then: sudo systemctl reload ssh" ;;
esac
[ "$(sget kbdinteractiveauthentication)" = yes ] && warn "keyboard-interactive auth is enabled (set KbdInteractiveAuthentication no)"
case "$(sget permitrootlogin)" in
yes) warn "root can log in with a password. Use 'PermitRootLogin prohibit-password' (Coolify still works with keys)" ;;
prohibit-password | without-password) pass "root login is key-only" ;;
no) info "root login is disabled. Coolify then needs a non-root user configured for this server" ;;
esac
[ "$(sget pubkeyauthentication)" = no ] && fail "public key authentication is disabled"
else
info "sshd not found, skipping"
fi
section "Authorized SSH keys (check you recognise every one)"
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
[ -f "$f" ] || continue
info "$f:"
ssh-keygen -lf "$f" 2>/dev/null | sed 's/^/ /'
done
section "Accounts"
extra_root=$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd)
if [ -n "$extra_root" ]; then fail "non-root accounts with UID 0: $extra_root"; else pass "only root has UID 0"; fi
for g in sudo admin wheel docker; do
members=$(getent group "$g" 2>/dev/null | cut -d: -f4)
[ -n "$members" ] && info "group '$g': $members$([ "$g" = docker ] && echo ' (docker group = root-equivalent)')"
done
section "Updates"
if have apt-get; then
if apt-config dump 2>/dev/null | grep -q 'APT::Periodic::Unattended-Upgrade "1"' && systemctl is-enabled unattended-upgrades >/dev/null 2>&1; then
pass "unattended security upgrades are enabled"
else
warn "unattended upgrades are off (sudo apt install unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades)"
fi
sec=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst .*-security')
if [ "$sec" -gt 0 ]; then warn "$sec security updates pending (sudo apt update && sudo apt upgrade)"; else pass "no pending security updates (as of the last apt update)"; fi
fi
[ -f /var/run/reboot-required ] && warn "a reboot is required to finish applying updates"
section "Summary for $(hostname)"
printf '%s%d FAIL%s, %s%d WARN%s\n' "$R" "$fails" "$N" "$Y" "$warns" "$N"
[ "$fails" -eq 0 ]external-check.sh
Ejecútalo desde tu portátil con las IPs públicas de tus servidores: bash external-check.sh 203.0.113.10 2001:db8:10::1.
#!/usr/bin/env bash
# Probe servers' PUBLIC addresses from outside to see which ports the internet can reach.
# Usage: bash external-check.sh <public-ip> [<public-ip> ...]
# Optional: PORTS="22 80 443 8000" ALLOWED_PUBLIC_PORTS="80 443" bash external-check.sh ...
# Run from a machine that is NOT one of the servers (e.g. your laptop). Do not use a Tailscale exit node while running it.
set -u
PORTS="${PORTS:-22 80 443 3000 3306 5432 6001 6002 6379 8000 8080 8443 9000 27017}"
ALLOWED_PUBLIC_PORTS="${ALLOWED_PUBLIC_PORTS:-80 443}"
[ $# -gt 0 ] || { echo "Usage: bash $0 <public-ip> [<public-ip> ...]"; exit 2; }
if [ -t 1 ]; then R=$'\e[31m' G=$'\e[32m' B=$'\e[1m' N=$'\e[0m'; else R='' G='' B='' N=''; fi
probe() {
if [ "$(uname)" = Darwin ]; then nc -z -G 3 -w 3 "$1" "$2" >/dev/null 2>&1; else nc -z -w 3 "$1" "$2" >/dev/null 2>&1; fi
}
bad=0
for host in "$@"; do
case "$host" in 100.*) echo "$host is a Tailscale IP; pass the server's PUBLIC IP instead."; continue ;; esac
printf '\n%s== %s ==%s\n' "$B" "$host" "$N"
for p in $PORTS; do
if probe "$host" "$p"; then
case " $ALLOWED_PUBLIC_PORTS " in
*" $p "*) printf ' %-6s open (expected)\n' "$p" ;;
*) printf ' %-6s %sOPEN <- reachable from the internet%s\n' "$p" "$R" "$N"; bad=1 ;;
esac
else
printf ' %-6s %sclosed%s\n' "$p" "$G" "$N"
fi
done
done
[ "$bad" -eq 0 ]