Security baseline: what a fresh VPS needs and why
This is both the spec for the modules and a learning reference. Each item has the threat it addresses, how it’s done per distro, the risk of breaking something, a check command and a rollback.
Version-sensitive facts were researched in Oct 2026. The tool must detect versions at runtime (
ssh -V,ssh -Q kex,/etc/os-release) rather than hard-code them.
0. Platform matrix
Section titled “0. Platform matrix”| Debian 12/13, Ubuntu 22.04/24.04/26.04 | Rocky/Alma 9/10, Fedora | Alpine 3.2x | |
|---|---|---|---|
| Packages | apt | dnf (Fedora: dnf5) | apk |
| Init | systemd | systemd | OpenRC |
| Firewall | ufw (Ubuntu), nftables (Debian) | firewalld | nftables / awall |
| MAC | AppArmor | SELinux | none |
| sudo | sudo; sudo-rs default on Ubuntu 26.04 | sudo, group wheel |
doas |
| Time | timesyncd; chrony+NTS on Ubuntu 25.10+ | chronyd | chronyd |
Distro changes that break old guides:
- OpenSSH 10.0 makes post-quantum
mlkem768x25519-sha256the default and removes DSA. - Debian 13 no longer reads
/etc/sysctl.conf; use/etc/sysctl.d/./tmpis now tmpfs. - Ubuntu ≥22.10 uses
ssh.socketactivation, so port changes needdaemon-reload+restart ssh.socket. - Rocky 10 disables root by default.
Preflight (always runs, read-only)
Section titled “Preflight (always runs, read-only)”- Read the facts: OS, init, virtualization (
systemd-detect-virt; skip kernel/sysctl/swap modules in LXC/OpenVZ), cloud-init, cloud provider. - Record the admin’s IP from
$SSH_CONNECTIONand the actually listening SSH port fromss -tlnp. - Ask: “Does your provider’s web console work?” Recommend a provider snapshot.
- Back up
/etc/ssh,/etc/sudoers*, the firewall ruleset andsysctl -abefore anything changes.
Module catalog
Section titled “Module catalog”Legend: Risk = chance of lockout or breakage (L/M/H). Profile = M(inimal) / W(eb-docker) / P(aranoid).
Base system
Section titled “Base system”| ID | Module | Why (threat) | How | Check | Risk | Profile |
|---|---|---|---|---|---|---|
base.update |
Full upgrade | Known CVEs in the shipped image | apt-get -y full-upgrade (with DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a) / dnf -y upgrade --refresh / apk -U upgrade |
/var/run/reboot-required, dnf needs-restarting -r |
L, irreversible | MWP |
base.time |
UTC + NTP (chrony) | Wrong time breaks TLS, TOTP, log correlation | timedatectl set-timezone UTC; chrony; NTS needs outbound TCP 4460 |
timedatectl, chronyc tracking |
L | MWP |
base.hostname |
Hostname | Clarity; sudo warns when /etc/hosts is wrong |
hostnamectl + /etc/hosts + cloud-init preserve_hostname: true |
hostnamectl |
L | MWP |
base.swap |
Swapfile (RAM ≤ 2 GB) | Out-of-memory kills during builds / apt |
fallocate, mkswap, fstab, vm.swappiness=10 (Btrfs: btrfs filesystem mkswapfile) |
swapon --show |
L | MW |
Accounts
Section titled “Accounts”| ID | Module | Why | How | Check | Risk |
|---|---|---|---|---|---|
user.admin |
Non-root sudo user + key | Root is the universal brute-force target; gives an audit trail per human | adduser/useradd -G wheel; copy authorized_keys with 0700/0600; restorecon -Rv ~/.ssh on SELinux |
id admin, sudo -l -U admin |
M: a passwordless user + sudo that requires a password = no root. Decide NOPASSWD vs set a password; visudo -cf |
user.lock |
Lock unused login accounts | Dormant accounts are a backdoor | usermod -L -s /usr/sbin/nologin |
passwd -S |
L |
user.pwpolicy |
pwquality (console/sudo only) | Weak local passwords | /etc/security/pwquality.conf minlen=14 |
L. Avoid password expiry (it blocks key logins, dev-sec #681) |
SSH (highest lockout risk; the dead-man switch is required)
Section titled “SSH (highest lockout risk; the dead-man switch is required)”Mechanism: write one drop-in, /etc/ssh/sshd_config.d/00-vps-ready.conf.
- sshd uses the first value it reads for each keyword, and the
Includeis at the top ofsshd_config. - So
00-beats cloud-init’s50-cloud-init.conf(which setsPasswordAuthentication yes). /etc/cloud/cloud.cfg.d/99-vps-ready.cfgwithssh_pwauth: falseis not needed: cloud-init only writes50-cloud-init.conf, which00-already beats (v0.3.0 does not write it).- If there’s no
Includeline (check Alpine/Arch), add one as the first line.
PermitRootLogin noPasswordAuthentication noKbdInteractiveAuthentication noPubkeyAuthentication yesAuthenticationMethods publickeyPermitEmptyPasswords noMaxAuthTries 3 # tip: tell users to set IdentitiesOnly in ~/.ssh/configLoginGraceTime 30ClientAliveInterval 300ClientAliveCountMax 2X11Forwarding noAllowAgentForwarding noPermitUserEnvironment noLogLevel VERBOSE # logs key fingerprintsAllowGroups sshusers # add admin + deploy to the group FIRSTCrypto: subtract from the defaults rather than pinning lists, which go stale. Build the list from ssh -Q kex.
- Use
^mlkem768x25519-sha256,…to prefer post-quantum KEX, and-ecdh-sha2-*style removals. - Set
RequiredRSASize 3072. - On RHEL-likes don’t set Ciphers/KEX/MACs in sshd: it silently overrides crypto-policies. Use
update-crypto-policies --set DEFAULT:NO-SHA1(orFUTUREfor paranoid).
Port change: keep 22 by default. Changing it only reduces log noise. It touches five places that can each lock you out: the firewall, the provider firewall, the SELinux semanage port, ssh.socket and fail2ban. A private network (Tailscale) is the real fix.
Validate before reload: sshd -t → sshd -T | grep -Ei 'passwordauth|permitroot|kbdinter' → sshd -T -C user=admin,host=x,addr=192.0.2.1 (for Match/AllowGroups). Then reload; existing sessions survive it.
Second factor (opt-in):
- FIDO2
ed25519-skkeys withverify-required(recommended; phishing-resistant). - Or TOTP via
pam_google_authenticator+AuthenticationMethods publickey,keyboard-interactive:pam.
Firewall
Section titled “Firewall”Policy: deny inbound, allow outbound, allow established/loopback/ICMP. Never block ICMPv6 (neighbor discovery and path-MTU discovery need it). Allow SSH before enabling.
| Family | How | Safety net |
|---|---|---|
| Ubuntu (ufw) | ufw default deny incoming; ufw limit 22/tcp; ufw allow 80,443/tcp; ufw allow 443/udp; ufw --force enable |
dead-man switch → ufw disable |
| RHEL (firewalld) | Apply runtime-only first, confirm, then --runtime-to-permanent |
Reloading or rebooting reverts by itself |
| Debian/Alpine (nftables) | Write /etc/nftables.conf → nft -c -f → nft -f |
Keep the previous ruleset file for a one-shot restore |
| vps-ready (v0.3.0, Debian and Ubuntu) | Its own table inet vps_ready in /etc/vps-ready/firewall.nft, loaded by vps-ready-firewall.service; not applicable while ufw or firewalld is active |
dead-man switch; rollback deletes only that table, so Docker’s and fail2ban’s chains stay |
Warning: Docker bypasses ufw/firewalld. Published ports are NATed before INPUT. Mitigations, best first:
"ip": "127.0.0.1"indaemon.jsonso ports default to loopback, with the reverse proxy as the only public entry.DOCKER-USERchain rules (the ufw-docker approach).- The provider firewall.
Also: ufw reset / nft flush ruleset deletes Docker’s chains, so run systemctl restart docker afterwards.
Check from outside: nmap -Pn -p- <ip> and nmap -6 ….
Brute-force protection
Section titled “Brute-force protection”| Option | Use when |
|---|---|
| OpenSSH PerSourcePenalties (9.8+, on by default) | Minimal profile; nothing to install |
fail2ban (ssh.bruteforce, web profile) |
Simple, single host. On Debian 12+ must set backend = systemd + install python3-systemd (there’s no auth.log by default) + banaction = nftables. ignoreip = admin IP |
| CrowdSec + firewall bouncer | Web host; also parses nginx/Caddy logs; shared blocklist (privacy trade-off) |
| sshguard | Alpine / tiny boxes |
Once SSH is key-only, brute force is mostly noise. Don’t oversell this module.
Automatic security updates
Section titled “Automatic security updates”| Family | How |
|---|---|
| Debian/Ubuntu | unattended-upgrades, 20auto-upgrades, our 52vps-ready drop-in: Automatic-Reboot "false" (or a reboot window), Remove-Unused-Kernel-Packages "true". Leave Docker’s repo out (an upgrade restarts containers unless live-restore is on) |
| RHEL 9/10 | dnf-automatic, upgrade_type = security, apply_updates = yes, timer |
| Fedora 41+ | dnf5-plugin-automatic |
| Alpine | /etc/periodic/daily/apk-upgrade; warn when the branch nears end of life |
Also make sure needrestart ≥ 3.8, because older versions have local-root CVEs (CVE-2024-48990 family).
Kernel / sysctl (/etc/sysctl.d/90-vps-ready.conf)
Section titled “Kernel / sysctl (/etc/sysctl.d/90-vps-ready.conf)”Safe set (all profiles):
- no ICMP redirects or source routing,
tcp_syncookies=1; rp_filter=2(loose:1breaks Tailscale routing);kptr_restrict=2,dmesg_restrict=1,unprivileged_bpf_disabled=1,bpf_jit_harden=2,yama.ptrace_scope=1;fs.protected_*,fs.suid_dumpable=0.
Never set:
net.ipv4.ip_forward=0: kills Docker, k8s and Tailscale (dev-sec ships this, and it’s their #1 Docker complaint).accept_ra=0: drops SLAAC IPv6 on many providers. Docker hosts with SLAAC needaccept_ra=2.
Paranoid only: kexec_load_disabled, sysrq=0, perf_event_paranoid=3. unprivileged_userns_clone=0 breaks rootless containers.
Logging and auditing
Section titled “Logging and auditing”- journald:
Storage=persistent,SystemMaxUse=1G,SystemKeepFree=2G,MaxRetentionSec=3month. - auditd (W: basic, P: full): watches on passwd/shadow/sudoers/sshd_config/cron/
/root/.ssh+ root execve by real users. Paranoid adds-e 2(immutable rules). - Remote logs (P): Vector / Fluent Bit / Alloy /
systemd-journal-upload. Logs that leave the box survive an attacker wiping them.
Integrity / MAC / filesystem
Section titled “Integrity / MAC / filesystem”- Lynis as an optional external audit. AIDE in paranoid only, and only useful if the database is kept off-host. rkhunter: legacy, off.
- AppArmor/SELinux: must be enforcing. Report it, and never disable it to “fix” something.
- /tmp:
nosuid,nodevby default.noexecis paranoid only, because it breaks pip/rustup/apt scripts. - Core dumps off;
umask 022(027 breaks reverse-proxy reads). - Listening services: the real check is
ss -tulpn. Maskrpcbind,avahi,cupsif present; keep MTAs on loopback.
Deployment readiness
Section titled “Deployment readiness”| ID | Module | Key points |
|---|---|---|
deploy.docker |
Docker Engine from the official repo | daemon.json: log-driver local with size limits, live-restore, no-new-privileges, userland-proxy:false, ip:127.0.0.1. The docker group is equivalent to root. Rootless/Podman as an option |
deploy.user |
deploy user |
No general sudo; sudoers allows only /usr/local/bin/deploy-app; restricted authorized_keys (restrict,command="…") for CI |
deploy.proxy |
Reverse proxy | Caddy by default (auto HTTPS); Nginx+certbot as an option; Traefik only behind a docker-socket proxy |
net.tailscale |
Private admin access | tailscale up, allow on tailscale0, then close public 22 as the very last step, behind its own dead-man switch |
ops.backup |
restic → S3/B2/SFTP | systemd timer, forget --prune, weekly check, DB dumps, healthchecks.io ping. Provider snapshots aren’t backups |
ops.monitor |
node_exporter / Beszel | Bind to localhost or the tailnet. Netdata listens publicly on :19999 by default |
Safe order of operations (what the engine enforces)
Section titled “Safe order of operations (what the engine enforces)”-
Preflight + backups + snapshot advice.
-
Update, time, hostname, swap.
-
Admin user + key + sudo → Challenge 1: the user opens a new terminal and runs
ssh admin@host sudo vps-ready confirm. This proves a fresh login with the right user, key and sudo all work. -
SSH drop-in:
sshd -t -fon the new file → write →sshd -t→ reload →sshd -T -Ceffective check → Challenge 2 from a new connection. -
Firewall: allow every current SSH port →
nft -c -f→ load → read the table back → confirm from a new connection.Since v0.3.0, steps 2 to 4 run in one transaction with one confirm (
apply --profile minimal): the dead-man switch is armed before the first change, and one new login as the admin proves the user, the sshd config and the firewall together. The confirm window is 5 minutes by default (--deadman 1mto30m;confirm --extendadds time), counted from when the changes are live. Without a confirm, or after a reboot during the window, everything reverts.--module user.adminfirst, then the rest, shows which step fails. -
Brute-force protection, updates, sysctl, journald, auditd.
-
Deployment modules.
-
Tailscale → confirm SSH over tailnet → close public 22 (dead-man).
-
Reboot → re-audit → report.
Why a new connection? sshd reloads and conntrack keep existing sessions alive. Your open terminal proves nothing.
Profiles
Section titled “Profiles”| Minimal | Web / Docker host | Paranoid | |
|---|---|---|---|
| Base (update, time, hostname, swap) | ✓ | ✓ | ✓ |
| Admin user, root off, SSH drop-in | ✓ | ✓ | ✓ + FIDO2/TOTP, no TCP forwarding |
| Firewall | SSH only | SSH, 80, 443 tcp+udp, Docker-safe | SSH via tailnet only |
| Brute force | PerSourcePenalties | fail2ban (CrowdSec opt-in later) | CrowdSec on proxy |
| Auto updates | security, no reboot | security + reboot window, Docker excluded | + auto reboot / livepatch |
| sysctl | safe | safe, forwarding on, accept_ra=2 | + paranoid keys |
| journald / auditd | yes / no | ✓ / basic | ✓ + remote / full + immutable |
| Docker, proxy, deploy user, backups | no | yes | yes (rootless preferred) |
| Tailscale + close 22 | offered | recommended | required |
Top 10 gotchas (each needs a test)
Section titled “Top 10 gotchas (each needs a test)”- cloud-init
50-cloud-init.confre-enables passwords →00-drop-in +ssh_pwauth: false+ verify withsshd -T. - Ubuntu
ssh.socketignores the port change →daemon-reload+restart ssh.socket. - SELinux: wrong
.sshlabels / unlabeled port →restorecon,semanage port. - Firewall enabled before SSH is allowed; IPv6 forgotten; provider firewall forgotten.
- Passwordless admin + password-required sudo + root disabled = no root.
AllowGroupsleaves out the admin/deploy user → check withsshd -T -C user=….MaxAuthTries 3+ many agent keys → “Too many authentication failures” → print anIdentitiesOnlytip.- Docker bypasses the firewall / a firewall reset wipes Docker chains /
ip_forward=0kills containers. accept_ra=0drops IPv6;rp_filter=1breaks Tailscale; Debian 13 ignoressysctl.conf.- Unattended reboots, Docker upgrades and needrestart restarts interrupt services → reboot window,
live-restore, exclude Docker’s repo.
Compliance mapping (not compliance claims)
Section titled “Compliance mapping (not compliance claims)”- Internal control IDs (e.g.
SSH-002) carry references: ComplianceAsCode rule IDs (sshd_disable_root_login), CIS section numbers per benchmark version, Lynis test IDs (SSH-7408). - We ship IDs only, never CIS text (licensing).
- For formal reports, hand off to
usg/oscap.
Feedback
Questions, ideas or problems with vps-ready.