A small cloud server, locked down
Debian 13 with keys-only SSH, automatic updates, and a host firewall loaded with a dead-man switch.
Why Web server, on the tour
On this page 6 sections
This site runs on the smallest server a cloud provider rents out, with nothing on it but nginx. Before anything is published, the server itself is made safe: one admin key, no passwords, updates that install themselves, and a firewall that lets in three ports. It has no way into the home network, so if it were ever taken over, it would lead nowhere.
The commands use this site's names, www.azit.se and azit.se, and the documentation address 203.0.113.10 for the server. Use your own.
1. The image#
Debian 13 (trixie): a small base system, conservative defaults, automatic security updates already enabled on its cloud images, and an rsync package that includes rrsync, which chapter 3 needs. If the provider lets you upload an image, Debian's own genericcloud image is the cleanest start; otherwise take the provider's Debian 13. Ubuntu works the same way, with small differences in paths.
If you install from an ISO instead: leave the root password empty (Debian then locks root and gives your user sudo), call the machine www, and select only SSH server in the software selection.
Find the provider's web console now, before you need it. It is a local login, outside SSH: whatever goes wrong with SSH or the firewall later, it still gets you in.
2. A key of its own#
A key made for this server only. One you also use for GitHub or at home would quietly connect the server to everything else that key opens.
ssh-keygen -t ed25519 -f ~/.ssh/azit-admin -C "admin www.azit.se"Set a passphrase when it asks. Then read the server's host key on the provider's console, so the first connection is not blind trust:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubssh -i ~/.ssh/azit-admin -o IdentitiesOnly=yes admin@203.0.113.10Answer yes only if the fingerprint ssh shows is the one on the console. IdentitiesOnly makes ssh offer this key alone, not every key in your agent.
If ssh asks for a password rather than the key's passphrase, the key is not on the server yet. Provider panels often put it on root, or only when the server is installed. Log in with the password once and copy the key over:
ssh-copy-id -i ~/.ssh/azit-admin.pub admin@203.0.113.10A short name for the server, so the options are written once. HostKeyAlias files the host key under that name, so a new address, or a DNS name later, does not look like a different server:
cat >> ~/.ssh/config <<'EOF'
Host azit
HostName 203.0.113.10
User admin
IdentityFile ~/.ssh/azit-admin
IdentitiesOnly yes
HostKeyAlias azit
EOFCheck
ssh azit 'id'After one more fingerprint question (the key is now filed under azit), your user with sudo among its groups.
3. SSH: keys only#
# Installed as /etc/ssh/sshd_config.d/10-hardening.conf.
# sshd uses the first value it reads, and this file sorts before cloud-init's 50-cloud-init.conf.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
AllowUsers admin deploy logpullKeep your session open until the last check below passes: it is your way back if something is wrong.
scp deploy/sshd-hardening.conf azit:sudo install -m 644 ~/sshd-hardening.conf /etc/ssh/sshd_config.d/10-hardening.conf
sudo sshd -t && sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|allowusers)'
sudo systemctl reload sshsshd -T prints the settings sshd will actually use, after every included file: if a line says yes, another file is winning. On Debian the service is called ssh, not sshd.
Check
ssh azit 'echo key works'
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password admin@203.0.113.10key works, then Permission denied (publickey). at once, without a password prompt: the server no longer offers passwords at all.
fail2ban is not needed here. With passwords off there is nothing to guess, and OpenSSH 10 already slows down addresses that keep failing (PerSourcePenalties, on by default; sudo sshd -T | grep -i persourcepenalties shows it).
4. Updates, and a reboot at four#
sudo apt update && sudo apt full-upgrade
uname -r; ls /boot/vmlinuz-*If a newer kernel is installed than the one running, reboot with sudo systemctl reboot and log in again. Security updates then install themselves every day: unattended-upgrades is part of Debian's cloud images. What it does not do on its own is restart into a new kernel, so a patched kernel can sit unused for weeks. This turns that on, at a quiet hour:
// Installed as /etc/apt/apt.conf.d/52unattended-upgrades-local.
// Security updates install daily (Debian's default); when one needs a reboot, it happens at 04:00.
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";- It reboots only when an update asks for it (
/var/run/reboot-required), not every night. A static site is gone for about half a minute. - A file of its own, beside the package's
50unattended-upgrades: upgrades never ask you to merge your changes into theirs.
sudo install -m 644 ~/unattended-upgrades.conf /etc/apt/apt.conf.d/52unattended-upgrades-localCheck
apt-config dump | grep -E 'Periodic::(Update-Package-Lists|Unattended-Upgrade) |Automatic-Reboot'
sudo unattended-upgrade --dry-run --debug 2>&1 | grep -i 'allowed origins'Both Periodic settings "1", Automatic-Reboot "true" at "04:00", and allowed origins that include codename=trixie-security. If that last one is missing, the image points at another mirror and nothing would ever be installed.
5. A name that stays#
On a cloud image, cloud-init sets the hostname at every boot, back to the provider's. Tell it not to, then rename:
# Installed as /etc/cloud/cloud.cfg.d/99-local.cfg.
# Keeps cloud-init from resetting the hostname (www) or rewriting /etc/hosts at boot.
preserve_hostname: true
manage_etc_hosts: falsesudo install -m 644 ~/cloud-init.cfg /etc/cloud/cloud.cfg.d/99-local.cfg
sudo sh -c "sed -i 's/^127\.0\.1\.1 .*/127.0.1.1 www.azit.se www/' /etc/hosts && hostnamectl set-hostname www"
sudo systemctl rebootBoth changes in one sudo, so sudo never has to look up a name that has just disappeared.
Check
ssh azit 'hostname; hostname -f'www and www.azit.se, after the reboot. Your prompt on the server now says admin@www, which also makes it harder to type a server command into a laptop terminal.
6. The firewall#
Two layers. If the provider has a firewall in its panel, use it: 80 and 443 from anywhere, and 22 from your own address only. On the server itself, nftables, from a file you keep in Git:
Show the whole file, 41 lines
#!/usr/sbin/nft -f
# www.azit.se host firewall: inbound 22/80/443 only, outbound open.
# Installed as /etc/nftables.conf; loaded at boot by nftables.service.
flush ruleset
table inet filter {
set ssh_v4 {
type ipv4_addr
flags dynamic
timeout 1m
}
set ssh_v6 {
type ipv6_addr
flags dynamic
timeout 1m
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
icmp type echo-request accept
meta l4proto ipv6-icmp accept
tcp dport 22 ct state new add @ssh_v4 { ip saddr limit rate over 10/minute } drop
tcp dport 22 ct state new add @ssh_v6 { ip6 saddr limit rate over 10/minute } drop
tcp dport { 22, 80, 443 } ct state new accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter;
}
}- In: replies to the server's own traffic, ping, all of ICMPv6, and new connections to 22, 80 and 443. Everything else is dropped. Out: open, for updates, certificates and time.
- ICMPv6 has to be allowed: without neighbour discovery and path MTU discovery, IPv6 breaks in confusing ways.
- Each address may open 10 new SSH connections a minute; the rest is dropped in the kernel, before sshd sees it.
sudo apt install nftables
sudo install -m 644 ~/nftables.conf /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf && echo OK-c checks the rules without loading them. Now load them with a dead-man switch: a timer that flushes every rule after five minutes, unless you stop it. A mistake that locks you out undoes itself.
sudo systemd-run --on-active=5min --unit=nft-deadman /usr/sbin/nft flush ruleset
sudo nft -f /etc/nftables.confCheck
sudo nft list set inet filter ssh_v4Log in from a second terminal first (ssh azit), then run this there: your address, with limit rate over 10/minute and an expires countdown. The rate limit saw your login and let it through.
sudo systemctl stop nft-deadman.timer
sudo systemctl enable --now nftablesCheck
sudo systemctl rebootLog in again after a minute: sudo nft list ruleset | head -3 shows table inet filter. The firewall comes up on its own, and lets you in.