Start with a Minimal Install and Immediate Patching

Every unnecessary package is an attack surface. When provisioning, choose the minimal or server profile - no desktop environment, no Bluetooth stack, no print services. After first boot, your first two commands should always be:

On Debian/Ubuntu systems, enable unattended-upgrades immediately after patching manually. On RHEL-family systems, use dnf-automatic. Do not rely on manual patching cadence for security updates.

Check what is actually listening before you do anything else. A fresh cloud image from some providers comes with services you did not ask for:

# Debian/Ubuntu
apt update && apt upgrade -y
apt autoremove --purge -y

# RHEL/AlmaLinux
dnf update -y
dnf autoremove -y

# See what's listening right now
ss -tulpn

# Remove obvious junk (example)
apt purge --auto-remove avahi-daemon cups rpcbind -y

Lock Down SSH: The First Line of Defense

SSH is the most attacked service on any internet-facing server. Shodan indexes roughly 23 million SSH endpoints, and automated brute-force starts within minutes of a new IP becoming reachable. The changes below are non-negotiable for any production system.

Disable password authentication entirely. Use Ed25519 keys - they are faster and more secure than RSA-2048. Move SSH off port 22 only if it genuinely reduces noise in your logs; it does not improve security against a targeted attacker but does cut automated scan traffic dramatically.

Set `LoginGraceTime 20` to limit the window for connection negotiation. Set `MaxAuthTries 3` and `MaxSessions 5`. Disable root login. If you need root access, use a sudo-enabled user and escalate. Restrict which users can log in with `AllowUsers` or `AllowGroups` - do not leave this open.

After editing, always validate the config before restarting the daemon:

# /etc/ssh/sshd_config - key directives
Port 2222
AddressFamily inet
ListenAddress 0.0.0.0

LoginGraceTime 20
PermitRootLogin no
MaxAuthTries 3
MaxSessions 5

PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KerberosAuthentication no
GSSAPIAuthentication no

X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PrintMotd no

AllowUsers deploy adminjane

Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
KexAlgorithms curve25519-sha256,diffie-hellman-group16-sha512

# Validate before reloading
sshd -t && systemctl reload sshd

Firewall: Default Deny with Explicit Allow

The only acceptable default firewall policy is DROP on INPUT and FORWARD, ACCEPT on OUTPUT (you can tighten OUTPUT later). We use nftables on systems running kernel 5.x and above - it replaces iptables and is the default on RHEL 9 and Ubuntu 22.04+.

Do not use ufw for production servers. It abstracts too much and makes auditing harder. Write nftables rules directly. The ruleset below is a starting point: it allows established connections, ICMP (restricted), your SSH port, and drops everything else. Adjust the SSH port to match whatever you set in sshd_config.

Save the ruleset to `/etc/nftables.conf` and enable the service. On AlmaLinux, make sure firewalld is stopped first or you will have conflicting rulesets:

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # Allow loopback
        iif lo accept

        # Allow established/related
        ct state established,related accept

        # Drop invalid
        ct state invalid drop

        # ICMP - rate limited
        ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } limit rate 10/second accept
        ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, nd-neighbor-solicit, nd-neighbor-advert } limit rate 10/second accept

        # SSH
        tcp dport 2222 ct state new limit rate 15/minute accept

        # HTTP/HTTPS - remove if not a web server
        tcp dport { 80, 443 } accept
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}

# Apply
nft -f /etc/nftables.conf
systemctl enable nftables
// advertisement

Kernel Hardening via sysctl

The kernel ships with several parameters in permissive states that you should tighten explicitly. These settings address IP spoofing, ICMP redirect acceptance, SYN flood protection, and restricting dmesg output to root.

Write these to `/etc/sysctl.d/99-hardening.conf` rather than `/etc/sysctl.conf` so they survive package updates. Run `sysctl --system` to apply without rebooting.

The `kernel.dmesg_restrict = 1` setting prevents unprivileged users from reading kernel ring buffer messages, which can leak memory addresses useful for local privilege escalation. The `kernel.kptr_restrict = 2` similarly hides kernel symbol addresses from /proc/kallsyms. Both are off by default on most distributions.

# /etc/sysctl.d/99-hardening.conf

# Network hardening
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_rfc1337 = 1
net.ipv4.ip_forward = 0

# Kernel hardening
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
kernel.randomize_va_space = 2
kernel.sysrq = 0
kernel.core_uses_pid = 1
kernel.yama.ptrace_scope = 1

# Apply immediately
sysctl --system

User and Privilege Management

Audit every account on the system. Any account with UID 0 that is not root is a backdoor by definition. Any account with a shell that does not need interactive login should have its shell set to `/usr/sbin/nologin` or `/bin/false`.

Lock accounts that should not be used interactively. Remove or lock the default accounts that some cloud providers create. Password aging should be enforced via `/etc/login.defs` and `chage`.

For sudo, never grant `NOPASSWD` unless there is a specific automation reason, and even then scope it to the exact command needed - not `/bin/bash`. Use `/etc/sudoers.d/` drop-in files rather than editing the main sudoers file directly. Always edit with `visudo` to catch syntax errors before saving.

On systems where you're running automated deployment pipelines, tools like those available at taskbotshub.ai can help enforce least-privilege patterns by generating scoped sudoers rules and service account configurations as part of your provisioning workflow - reducing the chance of overly broad permissions slipping through in a rush.

# Find all UID 0 accounts (should only be root)
awk -F: '$3 == 0 {print $1}' /etc/passwd

# Find accounts with login shells that shouldn't have them
awk -F: '$7 !~ /nologin|false/ {print $1, $7}' /etc/passwd

# Lock an unused account
usermod -L -s /usr/sbin/nologin olduser

# Set password aging: max 90 days, warn 14 days before
chage -M 90 -W 14 username

# Check current aging settings
chage -l username

# Scoped sudoers entry - edit via visudo
# /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp.service

# Verify no world-writable files owned by root
find / -xdev -user root -perm -o+w -not -type l 2>/dev/null

Configure auditd for Compliance and Forensics

auditd is the Linux kernel audit subsystem. Without it, you have no reliable record of who ran what, when files were modified, or when privilege escalation occurred. On a hardened server, auditd should be running before any applications start, and the rules should be immutable once loaded.

Install auditd, then load a ruleset based on the CIS Linux Benchmark or the DISA STIG. The auditd project ships example rules at `/usr/share/doc/auditd/examples/` on most systems. The key events to capture: all privilege escalation (sudo, su), all authentication events (PAM), modifications to /etc/passwd and /etc/shadow, changes to cron, and all setuid/setgid executions.

Set `-e 2` at the end of your rules file to make rules immutable until reboot. This prevents an attacker with root access from clearing audit rules to cover tracks - they can modify rules in memory but cannot make them survive a reboot without access to the rules file itself.

apt install auditd audispd-plugins -y   # Debian/Ubuntu
dnf install audit audit-libs -y         # RHEL

# /etc/audit/rules.d/hardening.rules

# Delete all existing rules first
-D

# Buffer size
-b 8192

# Failure mode: 1=printk, 2=panic
-f 1

# Watch authentication
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers

# Watch SSH
-w /etc/ssh/sshd_config -p wa -k sshd

# Watch cron
-w /etc/cron.d/ -p wa -k cron
-w /var/spool/cron/ -p wa -k cron

# Privilege escalation
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=4294967295 -k priv_esc

# Setuid/setgid
-a always,exit -F arch=b64 -S execve -C uid!=euid -F euid=0 -k setuid

# Immutable
-e 2

# Load rules
augenrules --load
systemctl enable --now auditd

# Query example: show all sudo events from last hour
aausearch -k sudoers --start recent | aureport -i
// advertisement

Filesystem Hardening and Mount Options

Several standard mount points should have restrictive options. `/tmp` should be mounted with `noexec,nosuid,nodev`. The same applies to `/var/tmp` and `/dev/shm`. Many exploits stage payloads in /tmp and execute them - `noexec` stops that cold.

For systems with a separate `/home` partition, add `noexec` there too unless users legitimately need to execute scripts from their home directories. On dedicated servers running a single application, this is almost always safe.

Set immutable flags on critical files using `chattr`. Once set to immutable, even root cannot modify the file without first removing the flag - which would itself be logged by auditd if configured correctly.

Also disable USB storage if the server is physical hardware in a datacenter. A cleaning crew or malicious insider with physical access should not be able to plug in a drive:

# /etc/fstab additions
tmpfs   /tmp        tmpfs   defaults,rw,nosuid,nodev,noexec,relatime   0 0
tmpfs   /dev/shm    tmpfs   defaults,rw,nosuid,nodev,noexec             0 0

# Bind /var/tmp to /tmp
/tmp    /var/tmp    none    bind                                         0 0

# Remount immediately without rebooting
mount -o remount /tmp

# Set immutable on critical files
chattr +i /etc/passwd
chattr +i /etc/shadow
chattr +i /etc/gshadow
chattr +i /etc/group

# Verify
lsattr /etc/passwd

# Disable USB storage
echo 'install usb-storage /bin/true' > /etc/modprobe.d/disable-usb-storage.conf
modprobe -r usb_storage 2>/dev/null || true

Fail2ban and Brute-Force Protection

fail2ban reads log files and bans IPs that trigger repeated failures. It works with nftables via the `nftables` action, which is more efficient than the legacy iptables actions. Configure it to watch SSH, and any other exposed services like nginx or postfix.

Set `bantime` to at least 1 hour for SSH. Set `findtime` to 10 minutes and `maxretry` to 4. The default settings in most packages are far too permissive - 10 retries in 10 minutes is enough to run a credential stuffing attack against common passwords.

For outbound privacy and secure tunneling between servers or when managing servers remotely from untrusted networks, a VPN with native Linux CLI support is practical. NordVPN has a native Linux client (https://nordvpn.com/?ref=PLACEHOLDER) that installs cleanly on Debian and RHEL systems and integrates with systemd, letting you route admin traffic through an encrypted tunnel without needing a full-stack VPN server of your own.

apt install fail2ban -y

# /etc/fail2ban/jail.d/sshd.local
[DEFAULT]
bantime  = 3600
findtime = 600
maxretry = 4
banaction = nftables-multiport
banaction_allports = nftables-allports

[sshd]
enabled  = true
port     = 2222
logpath  = %(sshd_log)s
backend  = %(sshd_backend)s
maxretry = 3

# Start and enable
systemctl enable --now fail2ban

# Check ban status
fail2ban-client status sshd

# Manually ban an IP for testing
fail2ban-client set sshd banip 198.51.100.42

# Unban
fail2ban-client set sshd unbanip 198.51.100.42

AIDE: File Integrity Monitoring

AIDE (Advanced Intrusion Detection Environment) builds a database of file hashes, permissions, and metadata. Run it nightly and compare against the baseline to detect unauthorized changes. This is your last-resort detection layer: if an attacker got in and modified system binaries, AIDE will catch it on the next check.

Initialize the database immediately after hardening and before deploying any application. Store a copy of the database on a read-only or external location - an attacker who has root can modify the AIDE database on disk to cover tracks if it is stored locally.

On a typical Ubuntu 24.04 base install, the initial database build takes about 90 seconds. Schedule the check via cron and email the report to a mailbox you actually monitor:

apt install aide -y

# Review /etc/aide/aide.conf before initializing
# Key: make sure /tmp and /proc are excluded

# Initialize the database (takes 60-120 seconds)
aide --init

# Move the new database into place
mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db

# Run a check manually
aide --check

# Cron job: daily check at 3am, email results
# /etc/cron.d/aide-check
0 3 * * * root /usr/bin/aide --check | mail -s "AIDE Report $(hostname) $(date +%Y-%m-%d)" secops@example.com

# Update database after legitimate changes (e.g., package update)
aide --update && mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
// advertisement

CIS Benchmark Scoring: Verify Your Work

After applying these controls, score the system against the CIS Benchmark using OpenSCAP. The `oscap` tool is in the repositories for both Debian and RHEL-family systems. On RHEL 9 and AlmaLinux, the SCAP Security Guide ships pre-packaged and includes profiles for CIS Level 1, CIS Level 2, and the DISA STIG.

A fully hardened system following this guide should score above 85% on CIS Level 1. Closing the remaining gap typically involves controls specific to your workload - SELinux policy tuning, application-specific configurations, or organizational policy items that require manual verification.

For teams managing multiple servers, integrating these checks into your CI/CD pipeline is the right approach. If you are building out automated compliance scanning as part of a DevOps workflow, taskbotshub.ai includes tooling for scheduling and reporting on OpenSCAP runs across a fleet, which removes the manual cron-and-email approach at scale.

# Debian/Ubuntu
apt install libopenscap8 ssg-debian -y

# AlmaLinux/RHEL
dnf install openscap-scanner scap-security-guide -y

# List available profiles
oscap info /usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml

# Run CIS Level 1 evaluation
oscap xccdf eval \
  --profile xccdf_org.ssgproject.content_profile_cis_level1_server \
  --results /tmp/oscap-results.xml \
  --report /tmp/oscap-report.html \
  /usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml

# View HTML report in browser or
grep 'pass\|fail' /tmp/oscap-results.xml | wc -l

# On AlmaLinux, apply CIS Level 1 remediation script (dry run first)
oscap xccdf eval \
  --remediate \
  --profile xccdf_org.ssgproject.content_profile_cis \
  /usr/share/xml/scap/ssg/content/ssg-almalinux9-ds.xml

SELinux and AppArmor: Mandatory Access Control

Mandatory Access Control is the difference between a compromised application and a compromised server. On RHEL-family systems, use SELinux in enforcing mode. On Ubuntu, AppArmor is active by default - verify it and add profiles for any application that does not already have one.

Never disable SELinux permanently. The correct response to an SELinux denial is to write a policy, not to run `setenforce 0`. Use `audit2allow` to generate policy from denials and load it as a local module. This takes 5 minutes and is permanent. Setting SELinux to permissive because something broke is a temporary diagnostic step only.

Check SELinux status and verify enforcing mode survives reboots via `/etc/selinux/config`. On AppArmor systems, list profile statuses and check for profiles in complain mode that should be in enforce mode:

# RHEL/AlmaLinux - SELinux
getenforce                          # Should return: Enforcing
sestatus -v | head -20

# Set to enforcing permanently
sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config

# Generate policy from recent denials
audit2allow -a -M myapp_local
semodule -i myapp_local.pp

# Check for SELinux booleans that weaken security
getsebool -a | grep -E 'on$' | grep -E 'ftp|http|ssh|write'

# Ubuntu - AppArmor
aa-status

# Move a profile from complain to enforce
aa-enforce /etc/apparmor.d/usr.sbin.nginx

# Install additional profiles
apt install apparmor-profiles apparmor-profiles-extra -y

# Reload all profiles
apparmor_parser -r /etc/apparmor.d/*