Setup recipe: a secure baseline for a new server
A ready-made first-boot script that adds a sudo user, turns off root and password logins over SSH, and sets up a firewall, fail2ban and security updates.
This is a setup script that does the first-hour security chores on a new server, so it comes up that way. It runs once, as root, on the first boot. It works on Ubuntu, Debian, AlmaLinux, Rocky Linux and Fedora, and stops with a clear message on anything else.
It does five things:
- Creates a user called
deploywithsudo, and copies root's SSH keys to it. - Changes SSH to keys only, with no root login, in its own file,
/etc/ssh/sshd_config.d/01-vps9-hardening.conf. It checks the result withsshd -tand takes the file out again ifsshdrefuses it. - Turns on the host firewall with only SSH (port 22) allowed in:
ufwon Ubuntu and Debian,firewalldon AlmaLinux, Rocky Linux and Fedora. - Installs fail2ban, which bans an address for an hour after five failed SSH logins in ten minutes.
- Turns on automatic security updates: security fixes only, with no automatic reboot.
It needs an SSH key on the server. If root has no key, it stops before changing anything: with password logins off and no key, you'd be locked out of SSH (the serial console would still let you in).
Deploy it
Deploy with VPSNine: this opens the order form with the script already in the setup script field (you log in first). Change USER_NAME at the top if you want a different name, pick at least one SSH key, and order.
With the API or the CLI, save the script below as secure-baseline.sh and send it as the setup script:
vps9 servers create --plan budget --image debian-13 --hostname web-1 --key key_… --setup-script ./secure-baseline.shYou can also run it by hand on a server you already have: copy it over and run sudo bash secure-baseline.sh.
The script
#!/bin/bash
# VPSNine setup recipe: secure baseline
# https://vpsnine.com/help/secure-server-setup-script
# A sudo user with root's SSH keys, SSH with keys only and no root login, a host firewall that
# allows SSH, fail2ban for SSH, and automatic security updates.
set -euo pipefail
umask 022
# ---- settings: change these before you run it ----
USER_NAME=deploy # the sudo user to create; it gets root's SSH keys
# Which family of distributions this is: Debian and Ubuntu, or AlmaLinux, Rocky Linux and Fedora.
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
*" debian "*|*" ubuntu "*) family=debian ;;
*" rhel "*|*" fedora "*|*" centos "*) family=rhel ;;
*) echo "This recipe supports Ubuntu, Debian, AlmaLinux, Rocky Linux and Fedora, not ${PRETTY_NAME:-this system}" >&2; exit 1 ;;
esac
case "$USER_NAME" in
""|*[!a-z0-9_-]*) echo "USER_NAME must be lower-case letters, digits, _ and -" >&2; exit 1 ;;
esac
# Turning off password logins with no key in place would lock you out of SSH: stop before changing anything.
if [ ! -s /root/.ssh/authorized_keys ]; then
echo "root has no SSH keys (/root/.ssh/authorized_keys): order the server with a key, or add one, then run this again" >&2
exit 1
fi
if ! grep -qsE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/' /etc/ssh/sshd_config; then
echo "/etc/ssh/sshd_config doesn't read /etc/ssh/sshd_config.d/: this system is too old for this recipe" >&2
exit 1
fi
echo "== Installing the packages"
if [ "$family" = debian ]; then
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y sudo ufw fail2ban python3-systemd unattended-upgrades
else
# fail2ban comes from EPEL on AlmaLinux and Rocky Linux; Fedora has it in its own repositories.
if [ "$ID" != fedora ]; then dnf -y install epel-release; fi
dnf -y install sudo firewalld fail2ban python3-systemd
dnf -y install dnf-automatic || dnf -y install dnf5-plugin-automatic
fi
echo "== The sudo user $USER_NAME"
id "$USER_NAME" >/dev/null 2>&1 || useradd -m -s /bin/bash "$USER_NAME"
# sudo without a password, since the account has none (set one with passwd and drop NOPASSWD: if you prefer)
echo "$USER_NAME ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/90-$USER_NAME"
chmod 440 "/etc/sudoers.d/90-$USER_NAME"
install -d -m 700 -o "$USER_NAME" -g "$USER_NAME" "/home/$USER_NAME/.ssh"
install -m 600 -o "$USER_NAME" -g "$USER_NAME" /root/.ssh/authorized_keys "/home/$USER_NAME/.ssh/authorized_keys"
if command -v restorecon >/dev/null 2>&1; then restorecon -R "/home/$USER_NAME/.ssh"; fi
echo "== SSH: keys only, no root login"
# sshd keeps the first value it reads for each setting, and reads sshd_config.d in name order: 01- comes first.
cat > /etc/ssh/sshd_config.d/01-vps9-hardening.conf <<'EOF'
# VPSNine setup recipe: secure baseline
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 4
EOF
if ! sshd -t; then
rm -f /etc/ssh/sshd_config.d/01-vps9-hardening.conf
echo "sshd refused the new settings, so they were removed again" >&2
exit 1
fi
for unit in ssh sshd; do
if systemctl is-active --quiet "$unit"; then systemctl reload "$unit"; break; fi
done
echo "== Firewall: SSH in, everything else in refused"
if [ "$family" = debian ]; then
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw --force enable
else
systemctl enable --now firewalld
firewall-cmd --permanent --add-service=ssh
firewall-cmd --reload
fi
echo "== fail2ban for SSH"
install -d -m 0755 /etc/fail2ban/jail.d
cat > /etc/fail2ban/jail.d/vps9-sshd.local <<'EOF'
# VPSNine setup recipe: secure baseline. Five failed logins in 10 minutes: banned for an hour.
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
EOF
if [ "$family" = debian ]; then printf 'banaction = ufw\n' >> /etc/fail2ban/jail.d/vps9-sshd.local; fi
systemctl enable fail2ban
systemctl restart fail2ban
echo "== Automatic security updates (no automatic reboot)"
if [ "$family" = debian ]; then
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
systemctl enable apt-daily.timer apt-daily-upgrade.timer
else
install -d -m 0755 /etc/dnf
cat > /etc/dnf/automatic.conf <<'EOF'
# VPSNine setup recipe: install security updates every day, and never reboot on our own.
[commands]
upgrade_type = security
download_updates = yes
apply_updates = yes
reboot = never
[emitters]
emit_via = stdio
EOF
if systemctl cat dnf5-automatic.timer >/dev/null 2>&1; then t=dnf5-automatic; else t=dnf-automatic; fi
systemctl enable --now "${t}.timer"
fi
echo "== Done. Log in as: ssh $USER_NAME@<your server's address>"Check the result
Log in as the new user, not root:
ssh deploy@203.0.113.42
sudo tail -n 20 /var/log/vps9-setup.log
cat /var/lib/vps9-setup.exit0 in the exit file means every step worked. Then check each part:
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication'
sudo ufw status # Ubuntu and Debian
sudo firewall-cmd --list-all # AlmaLinux, Rocky Linux and Fedora
sudo fail2ban-client status sshdBefore you open more ports
The host firewall now refuses everything but SSH. When you add a web server or anything else that listens, allow its port too: sudo ufw allow 443/tcp, or sudo firewall-cmd --permanent --add-service=https then sudo firewall-cmd --reload. The server firewall in the panel is separate: it filters before traffic reaches the server, and this script doesn't change it.
Docker publishes container ports by writing its own firewall rules, which go around ufw. If you add the Docker host recipe, publish ports on 127.0.0.1 and put a reverse proxy in front, or use the panel firewall.
The SSH hardening checklist explains each SSH setting, and the first-hour checklist covers the rest of a new server.