En su primera hora, un VPS nuevo con Ubuntu 24.04 debería quedar con un usuario sudo propio, SSH solo con clave y el acceso root desactivado, ufw con solo los puertos 22, 80 y 443, fail2ban, actualizaciones de seguridad automáticas, swap, la hora correcta y un journal con límite. Ejecutamos cada paso en Ubuntu 24.04.4 y los reunimos en un script que terminó en 25 segundos en nuestro banco de pruebas (sin full-upgrade). La mayor trampa que destapó la prueba: un archivo 50-cloud-init.conf puede mantener activo en silencio el acceso con contraseña mientras sshd -t informa de que todo está bien.
Cómo lo probamos
- Entorno: banco de pruebas de RichHost, un contenedor aislado (micro-VM) con 2 vCPU / 8 GB, Ubuntu 24.04.4 LTS, kernel 6.18; octubre de 2026. Paquetes del archivo oficial de Ubuntu: OpenSSH 9.6p1 (1:9.6p1-3ubuntu13.20), fail2ban 1.0.2-3ubuntu0.1, ufw 0.36.2, unattended-upgrades 2.9.1.
- Ejecutado de verdad: creación del usuario, comprobación de la configuración SSH con
sshd -tysshd -T, un sshd en marcha en el puerto 2222 con intentos de acceso, fail2ban con un bloqueo real en nftables, un archivo swap, una simulación de unattended-upgrade y una auditoría de puertos. - Límites: el contenedor no tiene systemd, así que
systemctlytimedatectlno se ejecutaron (en el script van marcados con [SYSTEMD]). No activamos ufw porque el banco se administra por red; sus reglas se generaron con--dry-run. La memoria es el PSS de/proc/PID/smaps_rollup. - Son cifras del banco de pruebas, no cifras de producción de RichHost.
Paso 1. Actualizar el sistema e instalar los paquetes
Antes de cambiar nada, abre la consola web (VNC) en el panel de tu proveedor: sigue funcionando aunque SSH o el cortafuegos queden mal configurados. Para el primer acceso, consulta cómo conectarse por SSH desde Windows, macOS y Linux. Después, actualiza:
apt update && apt full-upgrade -y
apt install -y openssh-server ufw fail2ban unattended-upgrades
[ -f /var/run/reboot-required ] && reboot
Las imágenes se generan con antelación, y se nota. Nuestra imagen 24.04.4 tenía 216 actualizaciones pendientes en el momento de la prueba, 186 de ellas del repositorio security:
$ apt-get -s full-upgrade | grep -c '^Inst'
216
$ apt-get -s full-upgrade | grep '^Inst' | grep -c security
186
Los cuatro paquetes de la segunda línea arrastraron 14 paquetes nuevos: 3,5 MB de descarga y 14,5 MB en disco. Actualizar antes de instalar fail2ban también importa porque el changelog de 1.0.2-3ubuntu0.1 recoge una corrección de errores con Python 3.12 (LP #2055114): las primeras compilaciones de 24.04 podían no llegar a arrancar.
Paso 2. Crear un usuario sudo
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
passwd deploy # the password is for sudo
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
echo 'ssh-ed25519 AAAA... you@laptop' > /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
$ id deploy
uid=1002(deploy) gid=1002(deploy) groups=1002(deploy),27(sudo),100(users)
El paso passwd no es para SSH, es para sudo. Sin él, el nuevo usuario entra con la clave pero no puede actuar como administrador. Esto es lo que obtuvimos antes de asignar una contraseña:
deploy$ sudo -n true
sudo: a password is required
Los permisos 700 en .ssh y 600 en authorized_keys no son un trámite: con el valor por defecto StrictModes yes, sshd ignora la clave si otros usuarios pueden escribir en el archivo o en el directorio. Genera la clave en tu propio ordenador con ssh-keygen -t ed25519.
Paso 3. Reforzar SSH y comprobar los valores efectivos
En Ubuntu 24.04, el archivo principal /etc/ssh/sshd_config incluye /etc/ssh/sshd_config.d/*.conf al principio del todo, y sshd usa el primer valor que lee para cada opción. Por eso, pon a tu archivo el prefijo 00:
# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
$ sshd -t && echo OK
OK
$ sshd -T | grep -E '^(permitrootlogin|passwordauthentication|allowusers|maxauthtries) '
maxauthtries 3
permitrootlogin no
passwordauthentication no
allowusers deploy
Ahora, el experimento que demuestra por qué sshd -T es la comprobación que cuenta. Añadimos el archivo que cloud-init deja en muchas imágenes cloud y renombramos el nuestro a 99-hardening.conf:
$ echo 'PasswordAuthentication yes' > /etc/ssh/sshd_config.d/50-cloud-init.conf
# with 00-hardening.conf:
passwordauthentication no
# with 99-hardening.conf:
$ sshd -t && echo OK
OK
$ sshd -T | grep ^passwordauth
passwordauthentication yes
La sintaxis es válida y, aun así, las contraseñas siguen permitidas. sshd -t solo detecta errores, así que mira siempre los valores efectivos. Después arrancamos un sshd real con esta configuración en el puerto 2222 e intentamos entrar:
deploy with key: login OK
deploy with password: Permission denied (publickey).
root with key: Permission denied (publickey).
admin (no such user): Permission denied (publickey).
# sshd log:
Accepted publickey for deploy from 127.0.0.1 port 60208 ssh2: ED25519 ...
User root from 127.0.0.1 not allowed because not listed in AllowUsers
Invalid user admin from 127.0.0.1 port 60244
En un servidor real, aplica el cambio y prueba el acceso en una ventana nueva sin cerrar la sesión actual:
systemctl daemon-reload
systemctl restart ssh.socket ssh.service
En 24.04, SSH arranca mediante ssh.socket, y sshd-socket-generator (incluido en el paquete) copia Port de la configuración al socket solo después de daemon-reload. Si te lo saltas, un puerto nuevo no funcionará. Estos dos comandos no se ejecutaron en nuestro contenedor.
Paso 4. Activar el cortafuegos ufw
ufw default deny incoming
ufw default allow outgoing
ufw limit 22/tcp
ufw allow 80,443/tcp
ufw --force enable
ufw status verbose
Añade primero la regla de SSH y solo después enable, o te quedarás fuera. En el banco no activamos ufw, pero revisamos las reglas de iptables que genera (ufw --dry-run):
-A ufw-user-input -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --set
-A ufw-user-input -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 -j ufw-user-limit
-A ufw-user-input -p tcp --dport 22 -j ufw-user-limit-accept
-A ufw-user-input -p tcp -m multiport --dports 80,443 -j ACCEPT
Es decir, limit rechaza la sexta conexión nueva desde una misma IP en 30 segundos. Una persona no lo notará, un escáner sí. No abras la base de datos ni Redis en el cortafuegos.
Paso 5. Configurar fail2ban
En 24.04 el paquete ya activa la jail de sshd: /etc/fail2ban/jail.d/defaults-debian.conf establece backend = systemd y banaction = nftables. Pon tus propios límites en un archivo aparte que las actualizaciones no sobrescriban:
# /etc/fail2ban/jail.d/10-sshd.local
[DEFAULT]
bantime = 1h
bantime.increment = true
findtime = 10m
maxretry = 5
banaction = nftables-multiport
[sshd]
enabled = true
port = 22
backend = systemd
$ fail2ban-client -t
OK: configuration test is successful
Probamos el bloqueo en vivo: una instancia separada de fail2ban leía el log del sshd de prueba (maxretry 3) e hicimos cuatro accesos fallidos. El bloqueo saltó 0,5 segundos después del tercero:
$ fail2ban-client status sshd
| |- Total failed: 4
|- Currently banned: 1
`- Banned IP list: 127.0.0.1
$ nft list table inet f2b-table
chain f2b-chain {
type filter hook input priority filter - 1; policy accept;
tcp dport 2222 ip saddr @addr-set-sshd reject with icmp port-unreachable
$ ssh -p 2222 deploy@127.0.0.1
ssh: connect to host 127.0.0.1 port 2222: Connection refused
Mientras tanto, el sitio en el puerto 8080 seguía respondiendo 200: nftables-multiport solo bloquea el puerto de la jail. Para desbloquearte, usa fail2ban-client set sshd unbanip IP, y añade la IP fija de tu oficina a ignoreip.
Paso 6. Actualizaciones de seguridad automáticas
# /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
# /etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
Un archivo aparte 52-…-local no entra en conflicto con el 50unattended-upgrades del paquete al actualizar. Una simulación con unattended-upgrade --dry-run -d en el banco mostró los orígenes y el volumen de trabajo:
Allowed origins are: o=Ubuntu,a=noble, o=Ubuntu,a=noble-security, o=UbuntuESMApps,a=noble-apps-security, o=UbuntuESM,a=noble-infra-security
Option --dry-run given, *not* performing real actions
Packages that will be upgraded: bison bsdutils bzip2 ca-certificates coreutils curl ... (202 packages)
All upgrades installed
La simulación tardó 362 segundos porque los paquetes se descargan de verdad. El reinicio automático a las 04:30 está pensado para las actualizaciones del kernel. Si no puedes permitirte una parada nocturna, pon "false" y reinicia a mano cuando aparezca /var/run/reboot-required.
Paso 7. Añadir swap
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
sysctl -p /etc/sysctl.d/99-swap.conf
$ swapon --show
NAME TYPE SIZE USED PRIO
/swapfile file 1024M 0B -2
En el banco (ext4, micro-VM) el archivo swap funcionó sin problemas; allí el valor por defecto de vm.swappiness es 60. En virtualización por contenedores como OpenVZ o LXC, el kernel del anfitrión suele rechazar swapon. El swap no sustituye a la RAM, solo te salva del OOM killer en los picos.
Paso 8. Zona horaria y NTP
timedatectl set-timezone Europe/Kyiv
timedatectl set-ntp true
timedatectl # expect: System clock synchronized: yes, NTP service: active
Cambia Europe/Kyiv por la zona horaria de tu proyecto, por ejemplo Europe/Madrid o America/Mexico_City (la lista completa sale con timedatectl list-timezones, y en el script de más abajo es la variable TZ_NAME). Ubuntu 24.04 sincroniza la hora con systemd-timesyncd. En nuestro contenedor, timedatectl responde System has not been booted with systemd as init system (PID 1). Can't operate., así que solo comprobamos que la zona existe: TZ=Europe/Kyiv date mostró 2026-10-25 02:59:25 EEST. La hora exacta no es un detalle estético: unos minutos de desfase rompen la verificación de certificados TLS, los códigos de un solo uso y la correlación de logs.
Paso 9. Limitar el journal de systemd
# /etc/systemd/journald.conf.d/00-limits.conf
[Journal]
SystemMaxUse=500M
SystemKeepFree=1G
MaxRetentionSec=1month
$ systemd-analyze cat-config systemd/journald.conf | grep -E '^(SystemMaxUse|SystemKeepFree|MaxRetentionSec)='
SystemMaxUse=500M
SystemKeepFree=1G
MaxRetentionSec=1month
Aplícalo con systemctl restart systemd-journald y comprueba el tamaño con journalctl --disk-usage. Sin límite, journald deja que el journal ocupe hasta el 10 % del sistema de archivos (4 GB como máximo), es decir, 2 GB en un disco de 20 GB. Para alertas de disco y memoria, consulta nuestra guía de monitorización del hosting.
Paso 10. Comprobar qué escucha en la red
$ ss -tulpn
tcp 0.0.0.0:8080 users:(("nginx",pid=15395,...))
tcp 0.0.0.0:8081 users:(("nginx",pid=15395,...))
tcp 127.0.0.1:3306 users:(("mariadbd",pid=13421,...))
tcp 0.0.0.0:2024 # bench service port
Esta es la auditoría de nuestro banco: MariaDB escucha solo en 127.0.0.1, que es lo correcto; nginx está abierto a todos porque es el servidor web. Cada línea con 0.0.0.0 o [::] debe estar permitida a propósito en ufw o ligada a localhost. Otros errores frecuentes se explican en 5 agujeros de seguridad en servidores.
Cuánta memoria cuesta el refuerzo de seguridad
Medimos el PSS de los procesos en el banco con nginx 1.24, PHP 8.3-FPM y MariaDB 10.11 tras 50 peticiones a WordPress. La pila del sitio usaba 167,6 MB (163,6 MB en reposo), fail2ban-server 26,9 MB y sshd 3,3 MB. El refuerzo cuesta unos 30 MB, así que ni siquiera un VPS de 1 GB tiene motivos para saltárselo. free -m no sirve para esta medición: en un banco compartido, el ruido de otros procesos era mayor que el propio efecto.

Qué hace cada paso y qué arriesgas sin él
| Paso | Qué hace | Riesgo sin él |
|---|---|---|
| 1. Actualizaciones | Cierra vulnerabilidades conocidas (186 actualizaciones de seguridad en nuestra imagen) | Un servidor con agujeros públicos desde el primer minuto |
| 2. Usuario sudo | Separa el trabajo diario de root | Un error o un script hackeado obtiene todos los permisos |
| 3. SSH solo con clave | Elimina la adivinación de contraseñas y el acceso root | Los bots empiezan a adivinar la contraseña de root en cuanto la IP está activa |
| 4. ufw | Solo están abiertos 22, 80, 443 | Base de datos, Redis o paneles expuestos por accidente |
| 5. fail2ban | Bloquea una IP tras fallos repetidos | Los escáneres prueban accesos sin trabas |
| 6. Actualizaciones automáticas | Instala parches de seguridad a diario | Los parches pasan semanas sin instalarse |
| 7. Swap | Reserva de memoria para los picos | El OOM killer detiene MariaDB |
| 8. Hora y NTP | Hora exacta en logs, cron y TLS | Errores de certificado y logs confusos |
| 9. Límite de journald | El journal se mantiene por debajo de 500 MB | El journal llena un disco pequeño |
| 10. ss -tulpn | Muestra todos los puertos abiertos | Un servicio en 0.0.0.0 que no conoces |
El script de checklist que ejecutamos
El script es idempotente: ejecutarlo otra vez no rompe nada. En el banco, la primera ejecución en modo CONTAINER_TEST=1 (sin full-upgrade, 14 paquetes instalados) tardó 24,6 segundos, y la segunda, 14,6 segundos. En un servidor real suma el tiempo del full-upgrade, que depende del número de actualizaciones. Guárdalo como vps-setup.sh y ejecútalo como root con tu clave pública:
SSH_PUBKEY='ssh-ed25519 AAAA... you@laptop' NEW_USER=deploy bash vps-setup.sh
#!/usr/bin/env bash
# Ubuntu 24.04 VPS: first-hour hardening checklist.
# Run as root on a fresh server, from a console session you will NOT close
# until you have logged in as the new user in a second window.
# Tested 2026-10 on Ubuntu 24.04.4 (RichHost test bench, isolated container).
# Steps marked [SYSTEMD] need a real VM with systemd; in the test container
# (CONTAINER_TEST=1) they are only printed, not executed.
set -euo pipefail
NEW_USER="${NEW_USER:-deploy}"
SSH_PUBKEY="${SSH_PUBKEY:?set SSH_PUBKEY='ssh-ed25519 AAAA... you@laptop'}"
SSH_PORT="${SSH_PORT:-22}"
TZ_NAME="${TZ_NAME:-Europe/Kyiv}"
SWAPFILE="${SWAPFILE:-/swapfile}"
SWAP_SIZE="${SWAP_SIZE:-1G}"
CT="${CONTAINER_TEST:-0}"
run_systemd() { if [ "$CT" = 1 ]; then echo "[SYSTEMD, not run here] $*"; else "$@"; fi; }
step() { printf '\n== %s ==\n' "$*"; }
step "1. Packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
if [ "$CT" = 1 ]; then apt-get -s full-upgrade | grep -c '^Inst' || true
else apt-get -y full-upgrade; fi
apt-get -y -q install openssh-server ufw fail2ban unattended-upgrades
step "2. Sudo user ${NEW_USER}"
id "$NEW_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$NEW_USER"
usermod -aG sudo "$NEW_USER"
# sudo needs a password: set one (SSH will still accept keys only)
if [ -n "${SUDO_PASS:-}" ]; then echo "${NEW_USER}:${SUDO_PASS}" | chpasswd; else passwd "$NEW_USER"; fi
install -d -m 700 -o "$NEW_USER" -g "$NEW_USER" "/home/$NEW_USER/.ssh"
printf '%s\n' "$SSH_PUBKEY" > "/home/$NEW_USER/.ssh/authorized_keys"
chown "$NEW_USER:$NEW_USER" "/home/$NEW_USER/.ssh/authorized_keys"
chmod 600 "/home/$NEW_USER/.ssh/authorized_keys"
id "$NEW_USER"
step "3. SSH hardening"
# 00- so it wins over 50-cloud-init.conf: in sshd the FIRST value read is used
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<EOF
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers ${NEW_USER}
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
EOF
mkdir -p /run/sshd
sshd -t && echo "sshd -t: OK"
sshd -T | grep -E '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowusers|maxauthtries) '
# Ubuntu 24.04 starts sshd via ssh.socket; a new Port needs daemon-reload
run_systemd systemctl daemon-reload
run_systemd systemctl restart ssh.socket ssh.service
step "4. Firewall (ufw)"
if [ "$CT" = 1 ]; then
# the test container is managed over the network: rules are generated, not applied
ufw --dry-run limit "${SSH_PORT}/tcp" | grep '^-A ufw-user-input'
ufw --dry-run allow 80,443/tcp | grep '^-A ufw-user-input'
else
ufw default deny incoming
ufw default allow outgoing
ufw limit "${SSH_PORT}/tcp"
ufw allow 80,443/tcp
ufw --force enable
ufw status verbose
fi
step "5. fail2ban"
cat > /etc/fail2ban/jail.d/10-sshd.local <<EOF
[DEFAULT]
bantime = 1h
bantime.increment = true
findtime = 10m
maxretry = 5
banaction = nftables-multiport
[sshd]
enabled = true
port = ${SSH_PORT}
# read the journal: works with or without rsyslog/auth.log
backend = systemd
EOF
fail2ban-client -t
run_systemd systemctl enable --now fail2ban
step "6. Automatic security updates"
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
cat > /etc/apt/apt.conf.d/52unattended-upgrades-local <<'EOF'
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
EOF
apt-config dump | grep -E '^(APT::Periodic::(Update|Unattended)|Unattended-Upgrade::Automatic-Reboot)'
step "7. Swap ${SWAP_SIZE}"
if ! swapon --show | grep -q "$SWAPFILE"; then
fallocate -l "$SWAP_SIZE" "$SWAPFILE"
chmod 600 "$SWAPFILE"
mkswap "$SWAPFILE" >/dev/null
swapon "$SWAPFILE"
[ "$CT" = 1 ] || echo "$SWAPFILE none swap sw 0 0" >> /etc/fstab
fi
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
[ "$CT" = 1 ] || sysctl -p /etc/sysctl.d/99-swap.conf
swapon --show
step "8. Time zone and NTP"
test -e "/usr/share/zoneinfo/$TZ_NAME" && echo "zone $TZ_NAME exists: $(TZ=$TZ_NAME date '+%F %T %Z')"
run_systemd timedatectl set-timezone "$TZ_NAME"
run_systemd timedatectl set-ntp true
step "9. journald limits"
mkdir -p /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/00-limits.conf <<'EOF'
[Journal]
SystemMaxUse=500M
SystemKeepFree=1G
MaxRetentionSec=1month
EOF
systemd-analyze cat-config systemd/journald.conf | grep -E '^(SystemMaxUse|SystemKeepFree|MaxRetentionSec)='
run_systemd systemctl restart systemd-journald
step "10. What listens on the network"
ss -tulpn | awk 'NR>1 {print $1, $5, $7}'
Las líneas marcadas con [SYSTEMD] solo se mostraron en el contenedor; en un VPS se ejecutan. Después del script, entra como deploy en una ventana nueva y ejecuta sudo whoami antes de cerrar la sesión de root.
Preguntas frecuentes
¿Conviene cambiar el puerto 22?
No necesariamente. Las claves y fail2ban protegen mucho mejor; otro puerto solo reduce el ruido en los logs. Si lo cambias, define SSH_PORT en el script: abre el puerto en ufw y lo escribe en la configuración de sshd, y daemon-reload lo traslada a ssh.socket.
¿Y si me quedo sin acceso por SSH?
Entra por la consola web del proveedor, corrige /etc/ssh/sshd_config.d/00-hardening.conf o ejecuta ufw disable, comprueba sshd -T y vuelve a intentarlo.
¿Funciona el script en LXC u OpenVZ?
En parte. Los pasos de usuario, SSH, fail2ban y actualizaciones funcionan, mientras que el swap, ufw y la hora suelen depender del anfitrión. Nuestro banco también es un contenedor, por eso esas partes van marcadas aparte.
¿Y si no quiero administrar un servidor?
Entonces quizá un VPS sea excesivo: compara las opciones en qué tipo de hosting necesita tu web. Para sitios WordPress o PHP, los planes de hosting compartido de RichHost son más sencillos: las actualizaciones del servidor y el cortafuegos corren a cargo del proveedor, y tú trabajas en nuestro propio panel de control con asistente de IA.



