Executive Summary

A security baseline is the foundation: OS-hardened, patched, with restricted access and audit trails. This guide covers minimal-install servers with hardened SSH, firewall (default-deny), LSM enforcement, least-privilege sudo, audit logging, and systemd hardening.

Goal: Reduce attack surface, detect breaches, and enforce privilege boundaries.


1. Minimal Install & Patching

Minimal Install

What it is:

  • Install only required packages (base + SSH + monitoring agent)
  • No GUI, X11, unnecessary daemons
  • Reduces vulnerabilities (fewer packages = fewer CVEs)

Install steps (Ubuntu/Debian):

# Netboot minimal install (server profile)
# Or, after full install, remove unnecessary packages

apt remove --purge ubuntu-desktop xserver-xorg* snapd apport cups
apt autoremove --purge
apt clean

# Keep core utilities
apt install --no-install-recommends \
  build-essential curl wget git tmux htop vim \
  openssh-server openssh-client openssh-sftp-server \
  ca-certificates apt-listchanges needrestart

# Disable swap (if not needed and running on SSD)
swapoff -a

Install steps (RHEL/CentOS/Fedora):

# Minimal server group
dnf groupinstall "Minimal Install" "Standard"

# Install hardening packages
dnf install --best \
  openssh openssh-clients \
  aide fail2ban unattended-upgrades \
  audit selinux-policy selinux-policy-devel

Automated Patching

What it is:

  • unattended-upgrades (Debian/Ubuntu): Auto-patch security updates
  • dnf-automatic (RHEL/Fedora): Auto-patch on schedule
  • Reduces window for known exploits; critical for compliance

Setup (Ubuntu/Debian):

apt install unattended-upgrades apt-listchanges needrestart

# Configure: /etc/apt/apt.conf.d/50unattended-upgrades
sudo tee /etc/apt/apt.conf.d/50unattended-upgrades > /dev/null << 'UNATTEND'
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
    "${distro_id}:${distro_codename}-updates";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "02:00";
Unattended-Upgrade::Mail "root";
Unattended-Upgrade::MailOnlyOnError "true";
Unattended-Upgrade::SyslogEnable "true";
Unattended-Upgrade::SyslogFacility "daemon";
UNATTEND

# Enable daily runs
sudo systemctl enable unattended-upgrades
sudo systemctl restart unattended-upgrades

Setup (RHEL/Fedora):

dnf install dnf-automatic

# Configure: /etc/dnf/automatic.conf
sudo tee /etc/dnf/automatic.conf > /dev/null << 'DNFAUTO'
[commands]
upgrade_type = security
apply_updates = yes
random_sleep = 0

[emitters]
system_mailer = yes
email_from = root@localhost
email_to = root

[base]
assumeyes = true
DNFAUTO

# Enable weekly patch (non-rebooting)
sudo systemctl enable dnf-automatic.timer
sudo systemctl start dnf-automatic.timer

# Optional: Add monthly reboot job
cat << 'REBOOT' | sudo tee /etc/cron.d/monthly-reboot
0 2 1 * * root /sbin/shutdown -r +60 "Monthly scheduled reboot"
REBOOT

Verify patching:

# Debian/Ubuntu
apt list --upgradable
sudo unattended-upgrade -d  # Dry-run

# RHEL/Fedora
dnf check-update
sudo dnf-automatic

2. SSH Hardening

Key-Based Authentication Only

What it is:

  • Disable password auth (no brute-force vulnerability)
  • Use SSH keys (Ed25519 or RSA 4096-bit) or FIDO2 hardware keys
  • Each admin/service gets unique key

Generate SSH keys (user local machine):

# Ed25519 (recommended, 256-bit security)
ssh-keygen -t ed25519 -C "[email protected]" -f ~/.ssh/id_ed25519 -N "passphrase"

# Or RSA 4096-bit (if Ed25519 not supported)
ssh-keygen -t rsa -b 4096 -C "[email protected]" -f ~/.ssh/id_rsa -N "passphrase"

# FIDO2 hardware key (e.g., YubiKey)
# Install: https://github.com/duo-labs/duo_unix
ssh-keygen -t ecdsa-sk -O resident -f ~/.ssh/id_ecdsa_sk -C "user@yubikey"

Copy public key to server:

# From local machine (one-time)
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
# Manually if ssh-copy-id unavailable:
# cat ~/.ssh/id_ed25519.pub | ssh user@server "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

Harden SSH config on server (/etc/ssh/sshd_config):

# Disable password authentication
PasswordAuthentication no
PubkeyAuthentication yes

# Disable root login (use sudo instead)
PermitRootLogin no

# Allow specific users (explicit allowlist)
AllowUsers user1 [email protected]/24  # user2 only from this subnet
DenyUsers nobody

# Disable tunneling, X11 forwarding (reduce attack surface)
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no

# SSH protocol 2 only
Protocol 2

# Strong ciphers (default often sufficient in modern OpenSSH)
Ciphers [email protected],[email protected],[email protected]
KexAlgorithms curve25519-sha256,[email protected]
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
MACs [email protected],[email protected]

# Restrict to specific ports (non-standard optional for obscurity)
Port 22

# Timeouts (limit lingering connections)
ClientAliveInterval 300       # Ping client every 5 min
ClientAliveCountMax 2         # Close if no response after 2 pings (10 min total)
LoginGraceTime 30

# Strict modes (check permissions on ~/.ssh, keys)
StrictModes yes

# Disable empty passwords
PermitEmptyPasswords no

# Limit authentication attempts
MaxAuthTries 3
MaxSessions 10

# Disable host-based authentication
HostbasedAuthentication no

Apply and verify:

sudo sshd -t                    # Syntax check
sudo systemctl restart ssh      # Apply changes
ssh-keyscan -t ed25519 server.example.com >> ~/.ssh/known_hosts  # Add to known_hosts
ssh [email protected]     # Should connect without password

Fail2Ban (Optional, for exposed servers)

What it is:

  • Monitors logs for failed logins
  • Auto-blocks IPs with iptables/nftables after N failures
  • Reduces brute-force attacks

Install & configure:

# Ubuntu/Debian
apt install fail2ban

# RHEL/Fedora
dnf install fail2ban python3-requests

# Create local config (don't edit fail2ban.conf directly)
sudo tee /etc/fail2ban/jail.local > /dev/null << 'FAIL2BAN'
[DEFAULT]
bantime = 3600              # Ban for 1 hour
findtime = 600              # Within last 10 minutes
maxretry = 3                # After 3 failures

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log  # or /var/log/secure on RHEL
maxretry = 3
FAIL2BAN

# Start & enable
sudo systemctl enable fail2ban
sudo systemctl restart fail2ban

# Monitor bans
sudo fail2ban-client status sshd
sudo fail2ban-client set sshd unbanip 192.168.1.100  # Unban an IP

3. Firewall (nftables or ufw)

What it is:

  • Kernel packet filtering (replaces iptables)
  • Rules in simple, efficient syntax
  • Default-deny inbound (explicit allowlist)

Install & basic config:

apt install nftables

# Create ruleset: /etc/nftables.conf
sudo tee /etc/nftables.conf > /dev/null << 'NFT'
#!/usr/bin/nft -f

flush ruleset

table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;  # Default-deny
    
    # Accept loopback (localhost)
    iif "lo" accept

    # Accept established/related connections
    ct state established,related accept

    # Accept SSH from anywhere (or restrict to trusted subnet)
    # Restrict: iif "eth0" ip saddr 10.0.0.0/8 tcp dport 22 accept
    tcp dport { 22 } accept comment "SSH"

    # Accept HTTP/HTTPS (if web server)
    tcp dport { 80, 443 } accept comment "Web"

    # Accept DNS (if running DNS server)
    udp dport { 53 } accept comment "DNS"
    tcp dport { 53 } accept comment "DNS-TCP"

    # Log dropped packets (for debugging)
    limit rate 5/minute log prefix "nft_drop: "

    # Drop everything else
    counter drop
  }

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

  chain output {
    type filter hook output priority 0; policy accept;  # Allow outbound
  }
}
NFT

# Validate
sudo nft -c -f /etc/nftables.conf

# Load rules
sudo systemctl restart nftables

# Verify
sudo nft list ruleset

Update firewall rules at runtime:

# Reload from file
sudo nftables reload

# Or, direct commands
sudo nft add rule inet filter input tcp dport 8080 accept comment "Custom app"
sudo nft delete rule inet filter input tcp dport 8080  # Remove

# Monitor packet counts
sudo nft list ruleset  # Shows counters

ufw (Simpler Alternative)

What it is:

  • Frontend to iptables/nftables
  • Simpler syntax, good for beginners

Install & basic config:

apt install ufw

# Enable and set defaults
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow SSH (before enabling!)
sudo ufw allow 22/tcp comment "SSH"

# Allow HTTP/HTTPS
sudo ufw allow 80/tcp comment "HTTP"
sudo ufw allow 443/tcp comment "HTTPS"

# Enable firewall
sudo ufw enable

# Verify
sudo ufw status verbose

4. Linux Security Modules (LSM)

SELinux (RHEL/Fedora/CentOS)

What it is:

  • Type enforcement (TE): Security contexts on files, processes, ports
  • Mandatory Access Control (MAC) prevents even root from unauthorized actions
  • Policy: targeted (default, confines daemons) or strict (all processes)

Check status:

getenforce
sestatus -v

Configure:

# Edit policy: /etc/selinux/config
sudo sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config
sudo sed -i 's/^SELINUXTYPE=.*/SELINUXTYPE=targeted/' /etc/selinux/config

# Apply (requires reboot)
sudo reboot

# After reboot, verify
getenforce  # Should output: Enforcing

# Monitor denials
sudo tail -f /var/log/audit/audit.log | grep denied

Generate policy rules from denials:

# Find denials
sudo grep denied /var/log/audit/audit.log | audit2why

# Generate allow rules (review before applying!)
sudo grep denied /var/log/audit/audit.log | audit2allow -a

# Apply custom policy
sudo audit2allow -a -M custom_policy
sudo semodule -i custom_policy.pp

AppArmor (Ubuntu/Debian)

What it is:

  • Path-based access control (simpler than SELinux)
  • Policy for each application
  • Modes: complain (log only), enforce (block)

Check status:

aa-status
apparmor_status

Configure:

# Most Ubuntu packages come with AppArmor profiles
# Verify key profiles are enabled
sudo aa-enforce /etc/apparmor.d/usr.sbin.sshd
sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx

# Check denials
sudo tail -f /var/log/audit/audit.log | grep apparmor | grep denied

Generate profile for custom app:

# Put profile in complain mode (log violations, don't block)
sudo aa-complain /path/to/app

# Run app, generate profile
# ... run your app normally for 10+ minutes

# Generate/update profile from logs
sudo aa-logprof

# Switch to enforce mode
sudo aa-enforce /etc/apparmor.d/path.to.app

5. Least-Privilege Access (sudo)

sudo Without Full TTY Bypass

What it is:

  • sudo requires password/key (authenticate each use)
  • NOPASSWD only for non-interactive commands
  • Separate admin & app service accounts
  • Audit all sudo usage

Configure (/etc/sudoers):

# Edit safely using visudo
sudo visudo

# Add these lines:
# ===== Users =====
# Standard admin: requires password for all commands
%sudo ALL=(ALL:ALL) ALL

# Service account: limited sudo, no password (non-interactive only)
app-user ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /bin/systemctl status myapp

# Require password for all others
Defaults use_pty                # Use pseudo-terminal (prevent TTY bypass)
Defaults log_input, log_output  # Log all input/output to /var/log/sudo-io/
Defaults requiretty             # Require terminal
Defaults env_reset              # Clear environment variables
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# Remove NOPASSWD if possible; only for non-interactive services

Verify configuration:

sudo -l                         # List your sudo permissions
sudo -i whoami                  # Should return: root (and prompt for password)

# Check sudo logs
grep sudo /var/log/auth.log     # Ubuntu/Debian
grep sudo /var/log/secure       # RHEL/Fedora

6. Audit Logging (auditd)

Basic Audit Rules

What it is:

  • Kernel audit subsystem; logs sensitive system calls
  • Enables forensics (who accessed what file, when)
  • Compliance (PCI-DSS, HIPAA require audit trails)

Install & configure:

apt install auditd audispd-plugins

# Create rules file: /etc/audit/rules.d/custom.rules
sudo tee /etc/audit/rules.d/custom.rules > /dev/null << 'AUDIT'
# Remove any existing rules
-D

# Buffer Size
-b 8192

# Failure Mode (2 = kernel panic on write failure)
-f 1

# Audit the audit logs (meta-audit)
-w /var/log/audit/ -k auditlog

# Monitor sudo usage
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers

# Monitor SSH configuration
-w /etc/ssh/sshd_config -p wa -k sshd_config

# Monitor system calls: execve (command execution)
-a always,exit -F arch=b64 -S execve -F uid>=1000 -F auid!=-1 -k exec

# Monitor user/group modifications
-w /etc/group -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity

# Monitor network configuration
-w /etc/hosts -p wa -k network
-w /etc/hostname -p wa -k network
-w /etc/sysconfig/network -p wa -k network

# Make configuration immutable (can't be changed until reboot)
-e 2
AUDIT

# Load rules
sudo service auditd restart

# Verify
sudo auditctl -l  # List loaded rules

Query audit logs:

# View all audit logs
sudo ausearch -k sudoers              # Find sudoers access
sudo ausearch -k exec -ts today       # Commands executed today
sudo ausearch -m EXECVE -ts 10:00:00  # Commands at specific time
sudo aureport -au --summary           # User activity summary

7. systemd Unit Hardening

Secure Service Unit Template

What it is:

  • NoNewPrivileges=yes: Prevent privilege escalation via setuid
  • PrivateTmp=yes: /tmp isolated per service
  • ProtectSystem=strict: Mount /usr, /boot read-only
  • ProtectHome=read-only: Home directories read-only
  • CapabilityBoundingSet: Drop unnecessary capabilities (least privilege)

Example hardened service (/etc/systemd/system/myapp.service):

[Unit]
Description=My Secure Application
Documentation=https://docs.example.com/myapp
After=network-online.target
Wants=network-online.target

[Service]
# Basic config
Type=simple
User=app-user
Group=app-group
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp-server --config /etc/myapp/config.yaml
Restart=on-failure
RestartSec=5

# ===== SECURITY HARDENING =====

# Privilege & capabilities
NoNewPrivileges=yes                               # No setuid escalation
PrivateUsers=yes                                  # User namespace isolation
CapabilityBoundingSet=CAP_NET_BIND_SERVICE      # Only bind ports <1024
AmbientCapabilities=CAP_NET_BIND_SERVICE        # Ambient caps (inherited by children)

# Filesystem restrictions
PrivateTmp=yes                                    # Isolated /tmp
ProtectSystem=strict                              # /usr, /boot, /etc read-only
ProtectHome=yes                                   # Home dirs inaccessible
ProtectClock=yes                                  # No clock modification
ProtectHostname=yes                               # No hostname change
ProtectKernelLogs=yes                             # No kernel logs
ProtectKernelModules=yes                          # No module loading
ProtectKernelTunables=yes                         # No sysctl changes
PrivateDevices=yes                                # Private /dev

# Networking restrictions
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6  # Allowed address families
RestrictRealtime=yes                              # No realtime scheduling
RestrictNamespaces=yes                            # No namespace changes

# Syscall restrictions (optional, for high-security apps)
SystemCallFilter=@system-service                  # Allow common syscalls
SystemCallFilter=~@privileged @resources          # Deny privileged syscalls

# Seccomp (advanced: filter specific syscalls)
# SeccompFilter=/etc/apparmor.d/seccomp.bpf      # Custom seccomp BPF

# Resource limits
CPUQuota=50%                                       # Max 50% of 1 core
MemoryLimit=1G                                     # Max 1GB RAM
TasksMax=1000                                      # Max 1000 processes/threads

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target

Load and test:

sudo systemctl daemon-reload
sudo systemctl enable myapp.service
sudo systemctl start myapp.service

# Verify hardening
sudo systemd-analyze security myapp.service  # Show hardening score
sudo journalctl -u myapp.service -f          # Monitor logs

Security Baseline Checklist

Pre-Deployment

  • Minimal install (no GUI, unnecessary packages removed)
  • Automated patching enabled (unattended-upgrades/dnf-automatic)
  • SSH keys generated (Ed25519 or RSA 4096-bit, passphrase protected)
  • SSH public keys copied to ~/.ssh/authorized_keys
  • PermitRootLogin no enforced
  • AllowUsers whitelist configured
  • PasswordAuthentication no enabled
  • Firewall rules validated (default-deny inbound)
  • Required ports only (SSH, HTTP/HTTPS, services)
  • LSM policy loaded (SELinux enforcing or AppArmor enforce)
  • Policy exceptions documented
  • auditd rules loaded
  • sudo configured (NOPASSWD only for specific commands)
  • systemd hardening applied to critical services

Post-Deployment

  • SSH key login works without password
  • SSH password auth fails
  • Firewall rules working (nft list ruleset / ufw status)
  • Blocked ports reject connections
  • Audit logs being written (ausearch queries work)
  • Sudo usage logged (grep sudo /var/log/auth.log)
  • Automatic patches applied (check /var/log/unattended-upgrades/)
  • LSM denials monitored (no unexpected blocks)
  • Fail2ban blocks repeated failed logins (if enabled)

Ongoing

  • Weekly: Review audit logs for anomalies
  • Monthly: Test SSH key rotation
  • Monthly: Verify patch status (apt list --upgradable)
  • Quarterly: Test service restart (ensure hardening holds)
  • Quarterly: Review & update firewall rules

Common Pitfalls

Pitfall 1: Lockout Without Backdoor

Problem: Disable SSH password auth โ†’ forget to add SSH keys โ†’ locked out
Fix: Always copy public key BEFORE disabling passwords; test login before reboot

Pitfall 2: Firewall Blocks Legitimate Traffic

Problem: Default-deny firewall โ†’ forget to open required port โ†’ service unreachable
Fix: Document all open ports; test each port before deploying to production

Pitfall 3: SELinux Overly Restrictive

Problem: Policy too strict โ†’ all app requests denied โ†’ cryptic errors
Fix: Start in permissive mode, monitor denials, gradually move to enforce

Pitfall 4: sudo Misconfiguration

Problem: NOPASSWD for interactive commands โ†’ privilege escalation
Fix: Use NOPASSWD only for non-interactive, single-purpose commands (e.g., systemctl restart)

Pitfall 5: Audit Logging Disabled

Problem: Security incident โ†’ no logs to investigate โ†’ unable to respond
Fix: Enable auditd on day 1; test log queries regularly


Quick Hardening Checklist (One-Liner Setup)

#!/bin/bash
# Emergency hardening script (review before running!)

# 1. Update system
apt update && apt upgrade -y

# 2. Remove unnecessary packages
apt remove --purge xserver-xorg* snapd ubuntu-desktop apport cups -y
apt autoremove --purge -y

# 3. Install security tools
apt install -y openssh-server openssh-client fail2ban nftables auditd sudo

# 4. Enable unattended upgrades
apt install -y unattended-upgrades
systemctl enable unattended-upgrades

# 5. SSH hardening (backup first!)
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sshd -t && systemctl restart ssh

# 6. Enable firewall (default-deny)
systemctl enable nftables
systemctl start nftables

# 7. Enable auditd
systemctl enable auditd
systemctl start auditd

# 8. Verify
echo "=== Security Status ==="
getenforce 2>/dev/null || apparmor_status | head -5
nft list ruleset | head -5
auditctl -l | head -5

Further Reading