A practical, opinionated walkthrough of the steps that take a freshly provisioned cloud instance from "default" to "defensible" — without breaking the services you actually need to run.
A default server image is built for convenience, not survival. It ships with password authentication enabled, a permissive firewall, services listening on interfaces you never asked for, and a kernel tuned for general-purpose workloads. Within minutes of getting a public IP, automated scanners will start knocking on port 22, port 3389, and a few hundred others, looking for exactly those defaults.
Hardening is the process of systematically reducing that attack surface. The goal is not to build an impenetrable fortress — no such thing exists — but to raise the cost of an attack high enough that opportunistic adversaries move on to easier targets, and to ensure that if someone does get in, the blast radius is contained and the event is recorded.
Defence in depth. No single control is sufficient. A locked-down SSH config means little if an unpatched web app gives an attacker a shell, and a perfect firewall is useless if your private key sits unencrypted on a laptop. Layers compound.
This guide assumes a Debian or Ubuntu base (the commands translate cleanly to RHEL-family systems with minor substitutions), root or sudo access, and a server you can afford to lock yourself out of while you practise. Always keep a second session open when changing SSH or firewall rules.
The first rule of account security is that nobody logs in as root. Direct root login over the network is a gift to attackers: it is a known username, it has unlimited power, and a successful brute force needs only one credential rather than two.
# Create the user and add to the sudo group
adduser deploy
usermod -aG sudo deploy
# Copy your public key into place for that user
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
Every account, process, and service should run with the minimum permissions needed to do its job — and no more. A web server does not need to read other users' home directories. A backup script does not need write access to system binaries. When you grant sudo, prefer narrowly scoped rules over blanket access.
# /etc/sudoers.d/deploy — allow only specific commands
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx, \
/usr/bin/systemctl reload nginx
| Control | Command / file | Effect |
|---|---|---|
| Lock root password | passwd -l root | Disables password-based root login |
| Audit sudo group | getent group sudo | Lists everyone with admin rights |
| Expire stale accounts | chage -E | Sets an account expiry date |
| Find empty passwords | awk -F: '($2=="")' /etc/shadow | Flags accounts with no password |
Before disabling root, confirm your new admin user can sudo -i successfully in a separate session. Locking root with no working alternative is the single most common self-inflicted lockout.
SSH is almost always the primary remote-access path, which makes it the primary target. A small set of configuration changes eliminates the overwhelming majority of automated attacks against it.
Passwords can be guessed, phished, or reused. A 4096-bit RSA or, better, an Ed25519 key cannot be brute-forced in any meaningful timeframe. Once your keys are confirmed working, disable password auth entirely.
# /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
AllowAgentForwarding no
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers deploy
On modern Ubuntu cloud images, /etc/ssh/sshd_config.d/50-cloud-init.conf may re-enable PasswordAuthentication after your file is read, depending on lexical ordering. Name your override file with a higher number (e.g. 99-) and verify the effective config with sshd -T | grep -i passwordauth rather than trusting the file you edited.
# Always test the config before applying — a typo here locks you out
sshd -t && systemctl reload ssh
# Confirm the running daemon agrees with your intent
sshd -T | grep -E 'permitrootlogin|passwordauth|pubkeyauth'
Moving SSH off port 22 is security theatre against a targeted attacker — a port scan finds it in seconds. But it does dramatically cut the volume of automated log noise, which makes real anomalies easier to spot. It is a convenience, not a control. Spend your effort on keys and rate limiting first.
A host firewall enforces a simple, powerful policy: deny everything inbound except what you explicitly permit. Even if a service is accidentally left listening, the firewall keeps it unreachable from the outside.
# /etc/nftables.conf — a minimal default-deny ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
# Allow established and related connections
ct state established,related accept
ct state invalid drop
# Loopback is always trusted
iif lo accept
# ICMP for diagnostics (rate-limited)
ip protocol icmp limit rate 5/second accept
ip6 nexthdr icmpv6 limit rate 5/second accept
# Public services
tcp dport { 22, 80, 443 } accept
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}
Firewalls evaluate rules top to bottom and stop at the first match. A common, painful mistake is placing a broad accept or a logging rule before the specific rule you intended to hit — for example, accepting all UDP before a more specific QUIC rule on port 443 can have the matching/counters behave differently than expected. When a rule "isn't working," check whether an earlier rule already consumed the packet.
Use nft list ruleset to see the live, fully expanded ruleset — including any rules added dynamically by other tools. The on-disk config file is intent; the live ruleset is reality.
| Task | nftables | iptables (legacy) |
|---|---|---|
| List rules | nft list ruleset | iptables -L -n -v |
| Add accept rule | nft add rule ... | iptables -A INPUT ... |
| Flush all | nft flush ruleset | iptables -F |
| Persist | /etc/nftables.conf | iptables-save |
The vast majority of real-world compromises exploit vulnerabilities for which a patch already exists. Timely updates are the single highest-leverage security activity available to you, and the cheapest.
apt install unattended-upgrades apt-listchanges
# /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Mail "ops@example.com";
Automatic reboots close kernel vulnerabilities fastest but can interrupt services at unpredictable times. For a fleet, stagger reboots or gate them behind a maintenance window. For a single critical box, prefer notification over auto-reboot and apply kernel updates by hand.
# What's listening, and on which interface?
ss -tulpn
# Which packages have pending security fixes?
apt list --upgradable 2>/dev/null | grep -i security
The kernel exposes hundreds of tunables via sysctl. A focused subset improves both security and resilience against common network-level attacks.
# /etc/sysctl.d/99-hardening.conf
# --- Network spoofing & redirection protection ---
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.log_martians = 1
# --- SYN flood mitigation ---
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.tcp_synack_retries = 2
# --- Resource limits (avoid thread/connection exhaustion) ---
kernel.threads-max = 100000
fs.file-max = 2097152
# --- Kernel exposure reduction ---
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1
Apply with sysctl --system and verify individual values with sysctl net.ipv4.tcp_syncookies.
A kernel.threads-max set too low, combined with a process that leaks threads, will eventually make the system unable to fork — meaning you can't even SSH in to fix it. Size resource limits for your actual workload plus headroom, and monitor thread/FD counts so a leak is caught before it becomes a crisis.
Every running service is a potential entry point. The fastest way to shrink your attack surface is to stop running things you don't need.
systemctl list-units --type=service --state=runningsystemctl disable --now <service>ss -tulpn and confirm the listening sockets match your intent.If a service only serves other local processes (a database, a metrics exporter), bind it to 127.0.0.1 so the network can't reach it at all.
Directives like ProtectSystem=strict, PrivateTmp=true, and NoNewPrivileges=true confine a service even if it's compromised.
Use CapabilityBoundingSet= to strip every Linux capability a service doesn't actually need, such as CAP_SYS_ADMIN.
MemoryMax, TasksMax, and LimitNOFILE stop a misbehaving service from starving the whole host.
You cannot respond to what you cannot see. Comprehensive logging turns an invisible compromise into a recorded, investigable event — and is often a compliance requirement on top of that.
apt install auditd audispd-plugins
# Watch for changes to critical auth files
auditctl -w /etc/passwd -p wa -k identity
auditctl -w /etc/shadow -p wa -k identity
auditctl -w /etc/ssh/sshd_config -p wa -k sshd
# Review events by key
ausearch -k identity --start today
An attacker who gains root will try to erase their tracks. Forwarding logs to a separate, write-only collector (via rsyslog, journald remote, or an agent) means the evidence survives even if the host is wiped. Centralised logs also make correlation across a fleet possible.
When a service runs as a non-root user but its logrotate config assumes root ownership, rotation silently fails and logs grow without bound — or worse, the service can't write after rotation. Match the su / create directives in the logrotate stanza to the actual runtime user, and watch for held-open deleted file descriptors (lsof | grep deleted) eating disk.
Even with password auth disabled, automated clients will hammer your services. Fail2ban watches logs for failure patterns and temporarily bans the offending IPs at the firewall, cutting noise and blunting brute-force attempts.
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
maxretry = 3
bantime = 24h
Check the state of a jail with fail2ban-client status sshd. Tune bantime and findtime to your traffic — too aggressive and you'll ban legitimate users behind a flaky connection; too lenient and it provides little protection.
Fail2ban is a noise reducer and a speed bump, not a primary control. A distributed attack from thousands of IPs sidesteps per-IP rate limits entirely. Treat it as one layer on top of key-only auth and a default-deny firewall — never as a substitute for them.
Any service exposed over the network should encrypt its traffic. Modern automation makes free, auto-renewing certificates trivial — there's no longer any excuse for plaintext or expired certs.
# Issue and install a cert with acme.sh (standalone mode)
acme.sh --issue -d example.com --standalone --keylength 4096
acme.sh --install-cert -d example.com \
--key-file /etc/ssl/private/example.key \
--fullchain-file /etc/ssl/certs/example.fullchain.pem \
--reloadcmd "systemctl reload nginx"
A certificate that a browser trusts perfectly may still fail with other clients. Some VPN and IKEv2 clients ship a limited root store and won't accept a newer ECDSA-rooted chain that web browsers handle fine. When a cert "works in the browser but not in the app," the issue is almost always the trust chain, not the cert itself — switching the issuing CA to one whose root is widely embedded (e.g. a Sectigo/USERTrust chain) often resolves it.
| Symptom | Likely cause | Direction |
|---|---|---|
| Browser OK, IKEv2 fails | ECDSA root not in client store | Switch CA / use RSA chain |
| Issuance fails on port 80 | Port blocked or proxied | Use DNS-01 challenge |
| Cert expired silently | Renewal cron not firing | Check timer + reloadcmd |
| Behind CDN proxy | HTTP-01 hits the proxy | DNS-01 via provider API |
Issuing a cert is the easy part. The failure mode that actually causes outages is a renewal that silently stopped working months ago. Confirm the renewal timer is active, that the reloadcmd actually reloads the right service, and monitor expiry externally so you find out before your users do.
Run through this before declaring a server production-ready. Each item maps to a section above.
PasswordAuthentication no, verified with sshd -T, not just the config file.ss -tulpn output matches intent.127.0.0.1; systemd sandboxing applied where possible.Hardening is not a one-time event you complete and forget. Threats evolve, packages change defaults, and configuration drifts. The real win is making this baseline reproducible — captured in configuration management — so that every server in your fleet starts hardened and stays that way, and so the next box you provision inherits everything you learned the hard way on this one.