[B b65.ch](/)

![](https://www.b65.ch/images/hardened-cloud-init-debian-hero.jpg)

On this page

13.08.2026

# Hardening a server before it has a first login

* * *

There is a window between the moment a server gets an IP address and the moment you first SSH into it. On a fresh cloud VM that window is usually a few minutes long. During those minutes the box is running a stock SSH daemon on port 22, with whatever defaults the image ships, reachable from the entire internet.

Most hardening guides start after that window. You provision a server, you log in, you run through a checklist, and somewhere in there you change the SSH port and install fail2ban. That works, and I did it that way for years. The thing that bothered me was the ordering: for those first few minutes the machine is in exactly the state the checklist exists to prevent.

cloud-init closes that window. The config below is what I hand to a provider when I create a Debian 13 box. By the time the machine answers on the network, password authentication is already off, the firewall already defaults to drop, and fail2ban is already counting. There is no unhardened phase to catch up on.

## Why the ordering works

The interesting part of cloud-init is not that it can write config files. It is when it writes them.

Modules run in three stages, and you can read the exact order in `/etc/cloud/cloud.cfg` on any machine that has cloud-init installed. Three entries matter here. `write_files` runs in the init stage. `package_update_upgrade_install` runs in the final stage. And `scripts_user`, which is what actually executes your `runcmd` block, also runs in the final stage, after the package install.

That last point catches people out. `runcmd` is listed under `cloud_config_modules`, which suggests it runs in the config stage. It doesn't. The config-stage module only writes your commands into a script file. Execution is deferred to `scripts_user` near the end of the final stage.

So the real sequence is: your config files land on disk, then apt installs packages, then your commands run.

That gives you two things for free. First, `/etc/apt/apt.conf.d/99-no-recommends.conf` is already in place when apt processes the package list, so the no-recommends policy applies to the very first install rather than to everything after it. Second, `/etc/fail2ban/jail.local` is sitting there before fail2ban exists. When the package gets installed and the service starts for the first time, it reads your jail configuration. It never runs with defaults, not even for a second.

You are configuring software that isn't installed yet. Once you see it, you start structuring the whole file around it.

## The template

Replace the port, the username, and the public key. Then read the rest of this post before you use it anywhere real.

`#cloud-config # ============================================================= # Hardened Debian 13 baseline # Replace: SSH_PORT (2222), the username, and the public key. # ============================================================= timezone: Europe/Zurich disable_root: true ssh_pwauth: false users: - name: deploy gecos: Deployment account shell: /bin/bash lock_passwd: true groups: [users] sudo: "ALL=(ALL) NOPASSWD:ALL" ssh_authorized_keys: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIREPLACEMEWITHYOUROWNPUBLICKEY you@example.com package_update: true package_upgrade: true package_reboot_if_required: true packages: - ca-certificates - chrony - curl - fail2ban - gnupg - nftables - python3-systemd - unattended-upgrades - wget write_files: - path: /etc/apt/apt.conf.d/99-no-recommends.conf permissions: '0644' content: | APT::Install-Recommends "0"; APT::Install-Suggests "0"; - path: /etc/ssh/sshd_config.d/60-hardening.conf permissions: '0644' content: | Port 2222 PermitRootLogin no PasswordAuthentication no KbdInteractiveAuthentication no AuthenticationMethods publickey PermitEmptyPasswords no X11Forwarding no AllowAgentForwarding no MaxAuthTries 3 LoginGraceTime 20 AllowUsers deploy - path: /etc/nftables.conf permissions: '0755' content: | #!/usr/sbin/nft -f add table inet filter delete table inet filter table inet filter { chain input { type filter hook input priority filter; policy drop; ct state established,related accept ct state invalid drop iif lo accept ip protocol icmp accept ip6 nexthdr ipv6-icmp accept tcp dport 2222 ct state new accept # Web traffic, uncomment when needed: # tcp dport { 80, 443 } ct state new accept counter comment "unmatched inbound" } chain forward { type filter hook forward priority filter; policy drop; } chain output { type filter hook output priority filter; policy accept; } } - path: /etc/fail2ban/jail.local permissions: '0644' content: | [DEFAULT] banaction = nftables-multiport banaction_allports = nftables-allports [sshd] enabled = true backend = systemd port = 2222 maxretry = 3 findtime = 2d bantime = 3d - path: /etc/fail2ban/action.d/nftables-common.local permissions: '0644' content: | [Init] blocktype = counter drop - path: /etc/fail2ban/fail2ban.local permissions: '0644' content: | [DEFAULT] dbpurgeage = 30d - path: /etc/apt/apt.conf.d/50unattended-upgrades permissions: '0644' content: | Unattended-Upgrade::Origins-Pattern { "origin=Debian,codename=${distro_codename},label=Debian-Security"; "origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; }; Unattended-Upgrade::AutoFixInterruptedDpkg "true"; Unattended-Upgrade::MinimalSteps "true"; Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; - path: /etc/apt/apt.conf.d/20auto-upgrades permissions: '0644' content: | APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; - path: /etc/systemd/journald.conf.d/10-persistent.conf permissions: '0644' content: | [Journal] Storage=persistent runcmd: - systemctl restart systemd-journald - systemctl enable --now nftables.service - systemctl enable --now chrony.service - systemctl enable --now unattended-upgrades.service - systemctl disable --now ssh.socket 2>/dev/null || true - systemctl enable ssh.service - systemctl restart ssh.service - systemctl enable --now fail2ban.service final_message: "System ready after $UPTIME seconds"`

## SSH

The hardening goes into `/etc/ssh/sshd_config.d/60-hardening.conf` rather than into `sshd_config` itself. Debian ships an `Include /etc/ssh/sshd_config.d/*.conf` line at the top of the main file, and the numeric prefix controls ordering if you ever add a second drop-in. The reason to prefer this over editing `sshd_config` with sed is dpkg. The main file belongs to the openssh-server package, so a distro upgrade will eventually prompt you about a modified conffile, and you get to reconcile your changes by hand at the worst possible moment. A drop-in belongs to you and survives untouched.

One gotcha that will waste an afternoon if you hit it. Some distributions have moved sshd to socket activation, where systemd owns the listening socket and hands connections to sshd. Under socket activation the `Port` directive in `sshd_config` is ignored entirely, because sshd isn't the thing binding the port. Ubuntu made this switch in 22.10. Rather than shipping an `ssh.socket` drop-in and hoping I got the unit name right, the template disables socket activation and runs classic sshd. The `|| true` handles the case where `ssh.socket` doesn't exist.

`AuthenticationMethods publickey` sits alongside `PasswordAuthentication no` on purpose. The two are not redundant. Turning off password auth closes one path; `AuthenticationMethods` states positively which methods are acceptable, which also shuts the door on keyboard-interactive and various PAM routes that can otherwise stay reachable.

Then there's the port. Moving SSH off 22 is not a security measure, and anyone who tells you otherwise is selling something. A targeted attacker runs a port scan and finds you in seconds. What it does is remove the background radiation. A public IP on port 22 collects thousands of login attempts a day from broad scanning botnets, and every one of them lands in your logs. Move to a high port and that traffic goes to roughly zero. The value is that your auth log becomes readable and your fail2ban counters start meaning something. When three failed attempts show up on port 2222, someone actually looked for you.

## fail2ban

The defaults are five failures in ten minutes, banned for ten minutes. Those numbers were reasonable when the threat was one host hammering you in a tight loop, and they are close to useless against how brute-forcing actually works now. A patient attacker spreads attempts across a large pool of source addresses and paces them hours apart. Every individual IP stays comfortably under a ten-minute window, forever.

Three failures in a two-day window, banned for three days, changes the arithmetic. It costs you almost nothing if you use keys, because you won't generate failures. It costs an attacker their entire address pool over the course of a week.

`backend = systemd` tells fail2ban to read the journal directly rather than tailing `/var/log/auth.log`. On a modern Debian install rsyslog is not guaranteed to be present, so parsing a text file that may not exist is a bad default to rely on. This is the reason `python3-systemd` is in the package list. Without it the systemd backend fails to initialise, and the failure mode is quiet: fail2ban starts, reports the jail as active, and simply never sees anything.

`blocktype = counter drop` overrides the default reject. Reject sends an ICMP unreachable back, which is polite and tells the scanner their target exists. Drop says nothing at all, so the connection hangs until it times out. The `counter` keyword is the part I'd actually argue for, though. It makes nftables tally packets against the ban rule, so `nft list ruleset` shows you how much traffic each ban is absorbing.

## The firewall

Enabling nftables without writing a ruleset gets you an empty table and an accept policy, which is a firewall in name only. The ruleset here is a conventional default-deny setup: allow established and related, allow loopback, allow ICMP, allow the one port you need, drop everything else with a counter so you can see what got turned away.

The three lines at the top are worth explaining, because the obvious alternative causes a subtle problem:

`add table inet filter delete table inet filter`

Most example rulesets open with `flush ruleset`, which wipes every table in the kernel. That includes `inet f2b-table`, which is where fail2ban keeps its active bans. So the moment you reload nftables for an unrelated reason, months of accumulated bans vanish and every banned address gets a clean slate. The add-then-delete idiom resets only your own table and leaves fail2ban's alone. The `add` is there because `delete` on a nonexistent table is an error, and adding a table that already exists is not.

Two warnings about the forward chain. If you later install Docker, the `policy drop` on forward will break container networking, and the symptom is containers that can reach nothing with no obvious error. Docker manages its own rules and expects to be able to forward. Second, if this host is a jump host, note that `AllowAgentForwarding no` in the SSH config does not block `ProxyJump`, but if you add `AllowTcpForwarding no` it will, and the error message won't make that obvious.

## Automatic updates

`unattended-upgrades` is scoped to Debian security origins only. Both origin patterns are there because the naming changed across releases and carrying both costs nothing.

`Automatic-Reboot "false"` is deliberate. I want patches applied without me, and I want reboots to happen when I'm watching. A kernel update will sit there needing a restart, and that's fine; `package_reboot_if_required` handles the initial provisioning reboot, and after that it's my call.

The file people forget is `20auto-upgrades`. Writing `50unattended-upgrades` configures the behaviour but does not turn it on. Without those two `APT::Periodic` lines the timer never triggers an upgrade run, and you get a server that looks like it patches itself and doesn't. This one is easy to miss because nothing complains.

## The smaller pieces

`Storage=persistent` for journald means logs survive a reboot. Default on many images is volatile, which means the journal lives in tmpfs and disappears exactly when you most want to read it. It also matters for fail2ban's systemd backend, which loses its history across restarts otherwise.

chrony is there because accurate time is load-bearing for things that don't announce themselves. Certificate validation, log correlation across hosts, and fail2ban's own findtime arithmetic all assume the clock is right.

No-recommends keeps the install lean. A stock Debian cloud image plus a handful of packages pulls in a surprising amount of extra software with recommends enabled, and each package is attack surface you didn't choose and won't be watching.

## The trade-off I made deliberately

`sudo: ALL=(ALL) NOPASSWD:ALL` will bother some readers, and the objection is fair, so here's the reasoning rather than a silent config line.

The account has no password at all, thanks to `lock_passwd: true`, and password authentication is off in sshd. Nobody is ever typing a password on this machine. Given that, a sudo password prompt is not a second factor; it would be a second copy of the same secret, stored somewhere I'd have to manage. What actually guards root on this box is the private key.

The cost is real: anything that gets code execution as `deploy` is root immediately. If that's not an acceptable trade in your environment, drop `NOPASSWD`, set a strong password on the account, and keep it in your password manager. Automation gets messier, and that's the price.

`AllowUsers deploy` is the other line worth noticing. It means adding a user later requires remembering to update the SSH config too. I consider that a feature.

## Test it before you trust it

This config changes the SSH port and enables a default-deny firewall in the same boot. Get one detail wrong and you have a server you cannot reach, on a provider where console access may or may not be pleasant.

Spin up the cheapest VM your provider offers and boot the config there first. Once it comes up, check the things most likely to be wrong:

`ss -tlnp | grep sshd # is sshd on the port you expect? nft list ruleset # does the accept rule match that port? fail2ban-client status sshd # is the jail actually active? systemctl is-enabled unattended-upgrades journalctl -u cloud-final # did runcmd finish without errors?`

If you want to check the YAML before you ever boot it, cloud-init ships a validator:

`cloud-init schema --config-file hardened.yml`

And nftables can syntax-check a ruleset without loading it, which is the one command I'd insist on running:

`nft --check -f /etc/nftables.conf`

Destroy the test VM afterwards and use the config for real. The whole point is that the next server is hardened the moment it exists, and you never have to think about the checklist again.

© 2026 Simon Jenny [Blog (Englisch)](/blog/index.html) [Kontakt](/contact.html)

- [**{{title}}**  
  {{excerpt}}](%7B%7Burl%7D%7D)