If you’d rather automate this instead of applying it by hand, this entire guide is available as a ready-to-run Ansible playbook.
The following is a practical baseline for hardening a fresh Debian or Ubuntu server.
It consolidates the CIS Level 1 server controls, with a set of additional measures (auditing, kernel and filesystem hardening, brute-force protection, automatic security updates, firewall) that are commonly omitted, but very valuable in production.
The guide is split into two parts:
Overview: what hardening is, the philosophy, and how to use this document.
Implementation: concrete configuration, grouped by area, ready to be adapted into Ansible, shell scripts, or applied by hand.
1. Overview
1.1 What is hardening?
System hardening is the process of reducing a machine’s attack surface and increasing the cost of a successful compromise.
It rests on a few principles:
Least privilege: users, services, and files get only the access they strictly need.
Defense in depth: multiple independent layers, so one failure does not equal total compromise.
Auditability: generate and protect logs so that intrusions can be detected and investigated.
Secure defaults: make the safe configuration the default state of the machine.
The CIS Benchmarks (published by the Center for Internet Security) are the de-facto industry baseline for secure system configuration: hundreds of pages of concrete, prescriptive rules covering ~140 platforms (operating systems, databases, web servers, cloud, container runtimes).
Each Benchmark splits its controls into two profiles: this guide targets Level 1 (practical, prudent, no significant loss of functionality) and adds selected Level 2 / defense-in-depth items only where the cost is low.
1.3 How to read the implementation sections
Each control is presented with:
What: the change being made.
Why: the risk it mitigates.
How: the commands or configuration.
Verify: how to confirm it is applied.
1.4 Order of operations
A sensible sequence when applying these to a fresh machine:
Package cleanup and required tooling (§2)
Kernel modules and filesystem types disable (§3)
Kernel and network parameters via sysctl (§4)
Firewall (§5)
Filesystem mount options (§6)
SSH server hardening (§7)
PAM, password, and account policy (§8)
Sudo hardening (§9)
Auditing (§10)
Logging (§11)
Brute-force protection (§12)
Automatic security updates (§13)
Banners, MOTD, and shell environment (§14)
Mandatory Access Control / AppArmor (§15)
Final verification and compliance scan (§16)
CAUTION
Several changes (SSH, PAM, sudo, firewall) can lock you out if applied incorrectly.
Always keep a second authenticated session open while applying them, and test login from a new session before closing the old one.
For the firewall, allow your management IP / SSH first, then enable the default-deny policy.
1.5 What hardening does NOT protect you from
IMPORTANT
This guide reduces the probability and blast radius of a remote or lateral-movement compromise.
It does not protect against:
Accidental or malicious data loss: You need encrypted backups. The open-source tool I use in production is BareOS.
Physical access to the disk: you need full-disk encryption (LUKS) on laptops and bare-metal servers, without it, anyone with 30 seconds at the disk can mount it from a live USB and read /etc/shadow, application data, and TLS private keys in clear (Cloud VPS are out of scope, the trust boundary is the provider’s hypervisor, and you’ve already accepted it).
Application-layer vulnerabilities in the software the server actually runs.
2. Packages and Services
2.1 Remove unnecessary packages
What: Remove software not needed on a server.
Why: Every installed package is a potential vulnerability and part of the attack surface.
Safe-to-remove candidates on a typical server:
apt-get purge -y \ rsh-client rsh-redone-client \ talk \ nis \ ldap-utils \ xinetd# GUI / X11 components (servers do not need them)apt-get purge -y 'xserver-xorg*'apt-get autoremove --purge -y
WARNING
Do NOT blindly purgesnapd and multipath-tools, they’re sometimes flagged in CIS lists but:
snapd on Ubuntu may be a dependency of lxd, cloud-init bootstraps, and some vendor agents.
multipath-tools is mandatory on SAN/iSCSI/Fibre-Channel storage: removing it can render disks invisible after the next reboot.
Verify your environment first (snap list, multipath -l) before removing either.
2.2 Install required tooling
What: Install logging, time sync, auditing, and update tooling.
What: Stop and disable services that are installed but not required.
# Example: a mail transfer agent not needed on most app serverssystemctl disable --now postfix 2>/dev/null || true
Verify:
systemctl list-unit-files --state=enabled
2.4 Time synchronization
What: Ensure a single, working time source.
Why: Consistent timestamps are essential for log correlation and much more.
/etc/chrony/chrony.conf:
pool 2.debian.pool.ntp.org iburstdriftfile /var/lib/chrony/chrony.driftmakestep 1.0 3rtcsync
systemctl enable --now chronychronyc tracking
3. Kernel modules and filesystem types
What: Block the loading of kernel modules that have no business being on a server.
Why: Many filesystem drivers (cramfs, udf, …) and removable-media stacks (usb-storage, firewire-core) expand the kernel attack surface for zero operational benefit on a typical Linux server.
3.1 Disable unused filesystem modules
/etc/modprobe.d/cis-filesystem.conf:
install cramfs /bin/trueinstall freevxfs /bin/trueinstall jffs2 /bin/trueinstall hfs /bin/trueinstall hfsplus /bin/trueinstall udf /bin/true# squashfs left enabled — snapd / appimage use it
Then unload anything already in memory:
for m in cramfs freevxfs jffs2 hfs hfsplus udf; do modprobe -r "$m" 2>/dev/null || truedone
3.2 Disable USB storage (optional)
What: Block the kernel module that backs USB mass-storage devices.
Why: Stops an attacker with physical access from exfiltrating data or auto-running malware from a USB key. Skip if the server legitimately uses USB drives.
/etc/modprobe.d/cis-usb.conf:
install usb-storage /bin/true
3.3 Disable wireless interfaces (optional)
What: Disable Wi-Fi / Bluetooth radios on a wired-only server.
Why: Removes an entire wireless attack surface on machines that have no business broadcasting.
# Wireless: if `rfkill list` shows entries, block allrfkill block all# Bluetooth daemonsystemctl disable --now bluetooth 2>/dev/null || true
Verify:
lsmod | grep -E 'cramfs|udf|hfs|usb_storage|btusb' # should be empty
4. Kernel and Network Parameters (sysctl)
What: Apply secure kernel and network tunables.
Why: Disables routing/redirect behaviors a server should not have, mitigates spoofing and several local information leaks, and enables exploit mitigations (ASLR, ptrace scope, kernel pointer hiding).
About IPv6. This guide hardens IPv6 but does not disable it. Disabling IPv6 system-wide breaks Docker / Podman, NetworkManager, systemd-resolved. CIS L1 does not require disabling IPv6, only hardening it. If you have a documented reason to disable it, do so via the bootloader:
What: Default-deny incoming, default-allow outgoing, allow only the ports you actually serve.
Why: A firewall is the single most impactful hardening you’ll apply: it blocks every service that ever ends up listening on 0.0.0.0 by mistake.
Without it, the rest of this guide is bypassable by the first misconfigured daemon.
UFW is the simplest layer on Debian/Ubuntu: it generates nftables rules underneath.
For complex topologies (NAT, multiple interfaces, custom chains) configure nftables directly (I definitely recommend learning IPTables and use that).
5.1 UFW: simple default-deny
CAUTION
Allow SSH before enabling UFW, otherwise you’ll lock yourself out of a remote session immediately.
# 1. Set the defaultsufw default deny incomingufw default allow outgoing# 2. Allow SSH (the *only* port reachable until you add more)ufw allow OpenSSH # = port 22/tcp; replace with the actual port if you moved it# 3. (Optional) restrict SSH to the management subnet# ufw allow from 10.0.0.0/24 to any port 22 proto tcp# 4. Allow whatever else the server actually serves# ufw allow 80/tcp# ufw allow 443/tcp# 5. Enableufw --force enable
Verify:
ufw status verbose
5.2 Rate-limit SSH
Mitigates a brute-force flood before fail2ban even sees it:
ufw limit OpenSSH
UFW’s limit allows up to 6 connection attempts per 30s per source IP, then drops.
5.3 Persistence and logging
ufw logging on # logs to /var/log/ufw.logsystemctl enable ufw # survives reboot
6. Filesystem Mount Options
What: Apply noexec, nodev, nosuid to temporary and shared-memory filesystems; nodev on user partitions.
Why: Prevents executing binaries, creating device nodes, or honoring setuid bits in world-writable areas, a common foothold for attackers.
6.3 /home, /var/log, /var/log/audit, removable media
Add nodev to /home, and noexec,nodev,nosuid to any removable-media entries.
If /var/log and /var/log/audit are separate partitions (recommended on auditable systems), they should also carry nodev,nosuid.
Verify (nothing should be returned):
for p in /tmp /var/tmp /dev/shm; do for o in noexec nodev nosuid; do mount | grep -E "\s$p\s" | grep -qv "$o" && echo "$p missing $o" donedone
7. SSH Server Hardening
What: Lock down sshd to strong crypto, no root login, sane session limits.
Why: SSH is the primary remote entry point: weak settings invite brute force, MITM, and session hijacking.
Apply to /etc/ssh/sshd_config (or a drop-in in /etc/ssh/sshd_config.d/99-hardening.conf):
# LoggingLogLevel VERBOSE# AuthenticationPermitRootLogin noPermitUserEnvironment noPermitEmptyPasswords noHostbasedAuthentication noIgnoreRhosts yesMaxAuthTries 4LoginGraceTime 60# Prefer key-based auth; enable PAM for account/session processingUsePAM yesPubkeyAuthentication yes# Set to "no" once every user has a working key:PasswordAuthentication yesKbdInteractiveAuthentication no# Session controlsClientAliveInterval 300ClientAliveCountMax 3MaxSessions 10MaxStartups 10:30:60# Banner (configured in §14)Banner /etc/issue.net# Strong algorithms onlyCiphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctrMACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256# Optionally restrict who can log in# AllowGroups ssh-users
Validate config before restarting (prevents lockout from a typo):
Once every user has a working public key, set PasswordAuthentication no and add AuthenticationMethods publickey to enforce key-only login.
Both together eliminate the password-attack surface entirely.
For everything around keys (generation, distribution, agent forwarding, key rotation), see the SSH guide.
8. PAM, Password, and Account Policy
IMPORTANT
On Debian/Ubuntu, do not hand-edit/etc/pam.d/common-auth, common-password, common-account, common-session. They’re regenerated by pam-auth-update on every libpam-runtime package upgrade, and your changes will silently disappear.
The supported way is to deploy custom profiles in /usr/share/pam-configs/ and enable them via pam-auth-update --enable. The examples below follow that pattern.
8.1 Password complexity (pam_pwquality)
What: Require strong passwords.
Why: Strong passwords resist brute force and dictionary attacks.
The libpam-pwquality package ships its own pam-configs profile; enable it:
pam-auth-update --enable pwquality
8.2 Account lockout (pam_faillock)
What: Lock accounts after repeated failed logins.
Why: Mitigates brute-force attacks against local accounts (defense in depth on top of fail2ban which works at IP level).
/etc/security/faillock.conf:
deny = 5unlock_time = 900fail_interval = 900
Create a pam-configs profile /usr/share/pam-configs/faillock:
What: Limit su to members of wheel, kept intentionally empty.
Why: Pushes everyone toward sudo for granular control and per-command audit logging.
groupadd -f wheel
Add to /etc/pam.d/su (this file is not regenerated by pam-auth-update, safe to hand-edit):
auth required pam_wheel.so use_uid group=wheel
Keep wheel empty so nobody can su directly.
Add a user only when you have a documented, time-bounded reason.
8.7 Restrict root console login
What: Limit (or forbid) where root may log in directly.
Why: Ensures the console is physically secured and no unexpected terminals are allowed.
# Forbid root login from every tty (recommended on remote servers): > /etc/securettychmod 600 /etc/securetty# Alternative: allow only the physical console + tty1# printf "console\ntty1\n" > /etc/securetty
8.8 System-wide umask
What: Set a restrictive default file-creation mask for all users and services.
Why: A umask in /etc/profile.d/ only affects interactive login shells: services, cron jobs, sudo, and systemd units ignore it.
The right place is login.defs + pam_umask.
/etc/login.defs:
UMASK 027
Ensure pam_umask.so is in the session stack, it usually is by default on Debian/Ubuntu, verify with:
For interactive bash shells (belt-and-braces) also drop in /etc/profile.d/set_umask.sh:
umask 027
9. Sudo Hardening
What: Make sudo run in a PTY, log to a dedicated file, shorten credential caching, disable insecure features.
Why: Mitigates background-process attacks, simplifies auditing, and reduces the window where cached sudo credentials can be abused.
timestamp_timeout=5 is a compromise. CIS recommends 0 (always re-prompt) but it’s measurably annoying on heavy-administration boxes. Lower it on bastion hosts where the trade-off is worth it.
What: Record security-relevant events and protect the audit trail.
Why: Without auditd you cannot reliably detect or investigate privilege escalation, file tampering, and policy violations.
/etc/audit/rules.d/hardening.rules:
# ─── Authentication & account changes ───────────────────-w /etc/passwd -p wa -k identity-w /etc/group -p wa -k identity-w /etc/shadow -p wa -k identity-w /etc/gshadow -p wa -k identity-w /etc/sudoers -p wa -k scope-w /etc/sudoers.d/ -p wa -k scope# ─── Login records ──────────────────────────────────────-w /var/log/lastlog -p wa -k logins-w /var/log/faillog -p wa -k logins-w /var/run/utmp -p wa -k session-w /var/log/wtmp -p wa -k session-w /var/log/btmp -p wa -k session# ─── Privilege escalation ───────────────────────────────-w /usr/bin/sudo -p x -k privileged-w /bin/su -p x -k privileged# ─── Kernel module loading ──────────────────────────────-w /sbin/insmod -p x -k modules-w /sbin/rmmod -p x -k modules-w /sbin/modprobe -p x -k modules-a always,exit -F arch=b64 -S init_module,delete_module -k modules# ─── Time changes ───────────────────────────────────────-a always,exit -F arch=b64 -S adjtimex,settimeofday -k time-change-a always,exit -F arch=b64 -S clock_settime -k time-change-w /etc/localtime -p wa -k time-change# ─── DAC permission changes (chmod/chown family) ────────-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat -F auid>=1000 -F auid!=4294967295 -k perm_mod-a always,exit -F arch=b64 -S chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod-a always,exit -F arch=b64 -S setxattr,lsetxattr,fsetxattr,removexattr -F auid>=1000 -F auid!=4294967295 -k perm_mod# ─── Unauthorized access attempts (EACCES / EPERM) ──────-a always,exit -F arch=b64 -S open,openat,creat,truncate,ftruncate -F exit=-EACCES -F auid>=1000 -F auid!=4294967295 -k access-a always,exit -F arch=b64 -S open,openat,creat,truncate,ftruncate -F exit=-EPERM -F auid>=1000 -F auid!=4294967295 -k access# ─── Mount events ───────────────────────────────────────-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k mounts# ─── Network environment ────────────────────────────────-w /etc/hosts -p wa -k system-locale-w /etc/network/ -p wa -k system-locale-w /etc/netplan/ -p wa -k system-locale# ─── Make the config immutable until reboot (PLACE LAST) ─-e 2
Enable and load:
systemctl enable --now auditdaugenrules --loadauditctl -l # list active rules
Important
auditd generates tens of thousands of events per day even on a quiet server. Without a consumer that collects, parses and alerts on them, they stay forensic-only: useful after an incident, not for real-time detection.
Some useful open-source consumers are Wazuh or Elastic SIEM if you’re already in the ELK stack, or even Splunk in enterprise environments.
11. Logging
11.1 rsyslog with restrictive permissions
What: Ensure rsyslog is installed, running, and creates log files with restrictive permissions.
Why: Logs contain sensitive data (failed auth attempts, IP addresses, file paths) and must not be world-readable.
systemctl enable --now rsyslog
In /etc/rsyslog.conf:
$FileCreateMode 0640$DirCreateMode 0750
11.2 Permissions on existing logs
find /var/log -type f -exec chmod g-wx,o-rwx "{}" + \ -o -type d -exec chmod g-wx,o-rwx "{}" +
11.3 Remote log forwarding
What: Forward logs to a central host.
Why: If a host is compromised, remote copies preserve the evidence and centralized log aggregation makes correlation across hosts possible.
/etc/rsyslog.d/90-remote.conf:
# Plain TCP — fine on a private management LAN*.* @@loghost.example.com:514# Better: TLS-encrypted forwarding (requires rsyslog-gnutls and a CA-signed cert)# *.* @@(o)loghost.example.com:6514
systemctl restart rsyslog
INFO
For anything traversing untrusted networks, use rsyslog over TLS (omfwd with StreamDriverMode=1). See the TLS certificates reference for cert generation and inspection.
11.4 Log rotation
Ensure /etc/logrotate.conf and /etc/logrotate.d/* rotate logs per policy.
What: Lock down cron config files and directories so only root can read or modify them.
systemctl enable --now cron# Spool and config dirschown root:root /etc/cron.{hourly,daily,weekly,monthly,d}/chmod 0700 /etc/cron.{hourly,daily,weekly,monthly,d}/# Main config fileschown root:root /etc/crontabchmod 0600 /etc/crontab# allow/deny lists: prefer cron.allow over cron.denytouch /etc/cron.allowchown root:root /etc/cron.allowchmod 0600 /etc/cron.allowrm -f /etc/cron.deny# Same treatment for at(1)touch /etc/at.allowchown root:root /etc/at.allowchmod 0600 /etc/at.allowrm -f /etc/at.deny
12. Brute-Force Protection (fail2ban)
What: Automatically ban IPs that repeatedly fail authentication.
Why: Defense in depth on top of SSH and PAM lockout: banning at the network layer reduces log noise and slows attackers across multiple services.
/etc/fail2ban/jail.local:
[DEFAULT]bantime = 1hfindtime = 10mmaxretry = 5backend = systemd# Whitelist your management LAN — never ban yourselfignoreip = 127.0.0.1/8 ::1 10.0.0.0/24[sshd]enabled = true
systemctl enable --now fail2banfail2ban-client status sshd
13. Automatic Security Updates
What: Apply security patches automatically.
Why: Most compromises exploit known, already-patched vulnerabilities, so automated patching closes that window without operator intervention.
DANGER
In critical production servers you may want to do the opposite: disable unattended upgrades, so that anything important that you have running on them doesn’t randomly break all of a sudden.
/etc/apt/apt.conf.d/50unattended-upgrades - restrict to security, mail on failure, avoid surprise reboots in production:
Unattended-Upgrade::Allowed-Origins { "${distro_id}:${distro_codename}-security"; "${distro_id}ESMApps:${distro_codename}-apps-security"; "${distro_id}ESM:${distro_codename}-infra-security";};Unattended-Upgrade::Automatic-Reboot "false";Unattended-Upgrade::Remove-Unused-Dependencies "true";Unattended-Upgrade::MinimalSteps "true";// Mail address for upgrade reports (errors and important events)Unattended-Upgrade::Mail "root";Unattended-Upgrade::MailReport "on-change";
Verify the timer is active:
systemctl status unattended-upgrades.timersystemctl status unattended-upgrades.service
14. Banners, MOTD, and Shell Environment
14.1 Login banners
What: Display a legal warning before/at login and remove OS version details.
Why: Supports prosecution of unauthorized access (in many jurisdictions it’s a prerequisite) and hides version info useful to attackers.
cat > /etc/issue <<'EOF'WARNING: Authorized uses only. All activity may be monitored and reported.Unauthorized access is prohibited and will be prosecuted to the full extent of the law.EOFcp /etc/issue /etc/issue.net
Remove any \m \r \s \v escape sequences or OS references from /etc/motd, /etc/issue, and /etc/issue.net.
# Auto-logout after 15 min of inactivity (CIS L1 default; raise to 3600 if 1h is preferred)declare -xr TMOUT=900
The declare -xr makes TMOUTexported (so child shells inherit it) and readonly (so users can’t unset it).
15. Mandatory Access Control (AppArmor)
What: Ensure AppArmor is installed, enabled, and profiles are enforced rather than left in complain mode.
Why: Confines services to the files, capabilities, and network access they need, containing the blast radius of a compromise.
Review aa-status for profiles in complain mode and promote them to enforce after validating they don’t break functionality. To craft a profile for a new service, use aa-genprof (interactive) or aa-logprof (replay-based).
16. Final Verification and Compliance Scan
16.1 root PATH integrity
What: Ensure root’s PATH has no empty, trailing, or world-writable entries and no current directory.
Why: A poisoned PATH can trick root into running a malicious binary placed in a writable directory.
#!/bin/bashecho "$PATH" | grep -q "::" && echo "Empty directory in PATH (::)"echo "$PATH" | grep -q ":$" && echo "Trailing : in PATH"for x in $(echo "$PATH" | tr ":" " "); do if [ -d "$x" ]; then ls -ldH "$x" | awk ' $9 == "." {print "PATH contains current working directory (.)"} $3 != "root" {print $9, "is not owned by root"} substr($1,6,1) != "-" {print $9, "is group writable"} substr($1,9,1) != "-" {print $9, "is world writable"}' else echo "$x is not a directory" fidone
16.2 shadow group is empty
grep ^shadow:[^:]*:[^:]*:[^:]+ /etc/group # should return nothing
16.3 World-writable and unexpected SUID/SGID files
# World-writable files on local filesystems (should be empty or justified)df --local -P | awk 'NR!=1 {print $6}' | \ xargs -I '{}' find '{}' -xdev -type f -perm -0002# SUID / SGID inventory (review against a known-good baseline)find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -exec ls -l {} \;# Unowned files (orphaned UIDs/GIDs after package removal)find / -xdev \( -nouser -o -nogroup \) -ls
16.4 Automated compliance scan
Run a standardized audit to catch what manual checks miss and to track drift over time: