Infrastructure
- Linux
- Security
- Ubuntu
- Debian
- Docker
- NGINX
- Infrastructure
Linux Server Hardening: A Production Checklist for 2026
Linux hardening checklist: SSH, UFW, fail2ban, sysctl, NGINX TLS, Docker, unattended upgrades, logging, monitoring, and backup restore drills for production.
“Install Ubuntu, create a user, install Docker” is a tutorial. Hardening is a posture: how you authenticate, what the kernel accepts on the wire, which ports are exposed, how configs change over time, and whether you can restore from backup.
This guide is a single pass you can run on a fresh VPS — SSH, firewall, fail2ban, automatic patching, kernel sysctl, NGINX TLS and headers, container defaults, logging, monitoring, and backup verification. Follow it on staging before production.
Assumptions: Ubuntu 22.04 LTS or Debian 12 unless noted; you have out-of-band console access before you change SSH.
If this server will also run WireGuard or another edge service, harden first, then follow our WireGuard production guide for tunnel config, DNS, and peer lifecycle — not the other way around.
Layer 1: Initial Access Setup
Create a non-root user immediately
Never operate as root. Create a dedicated admin user with sudo access:
# As root on initial login
adduser adminuser
usermod -aG sudo adminuser
# Verify
id adminuser
# uid=1001(adminuser) gid=1001(adminuser) groups=1001(adminuser),27(sudo)
For Ubuntu, the sudo group is sudo. For Debian, it's sudo as well but may need apt install sudo first.
Set up SSH key authentication before anything else
Generate a key pair on your local machine (not the server):
# On your local machine
ssh-keygen -t ed25519 -C "adminuser@yourserver" -f ~/.ssh/yourserver_ed25519
# Enter a strong passphrase — this protects the key if your laptop is stolen
Ed25519 is preferred over RSA-4096 and ECDSA: smaller keys, faster operations, same security level.
Copy the public key to the server:
ssh-copy-id -i ~/.ssh/yourserver_ed25519.pub adminuser@<server_ip>
# Or manually:
# cat ~/.ssh/yourserver_ed25519.pub | ssh adminuser@<server_ip> "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
Verify you can log in with the key before disabling passwords:
ssh -i ~/.ssh/yourserver_ed25519 adminuser@<server_ip>
Layer 2: SSH Hardening
The SSH daemon config is at /etc/ssh/sshd_config. Open it and make these changes:
# Disable root login — most critical setting
PermitRootLogin no
# Disable password authentication — use keys only
PasswordAuthentication no
# Renamed to KbdInteractiveAuthentication in OpenSSH 8.7 (Ubuntu 22.04+)
# Both directives are accepted for backwards compatibility
KbdInteractiveAuthentication no
# Keep UsePAM yes on Ubuntu/Debian — disabling it breaks account expiry,
# /etc/security/limits.conf enforcement, and other PAM session features.
# PasswordAuthentication no is the correct way to disable passwords.
UsePAM yes
# Disable X11 forwarding (rarely needed, attack surface)
X11Forwarding no
# Disable unused authentication methods
KerberosAuthentication no
GSSAPIAuthentication no
# Restrict who can log in (replace with your actual username)
AllowUsers adminuser
# Or restrict by group: AllowGroups sshusers
# Reduce brute-force window
MaxAuthTries 3
MaxSessions 3
# Disconnect idle sessions (in seconds)
ClientAliveInterval 300
ClientAliveCountMax 2
# Log level — VERBOSE logs key fingerprints used for login
LogLevel VERBOSE
# Disable empty passwords (should be off by default but be explicit)
PermitEmptyPasswords no
# Disable .rhosts files
IgnoreRhosts yes
# Disable host-based authentication
HostbasedAuthentication no
# Change default port — obscures your server in automated scans
# Pick an unprivileged port (1024–65535) or leave as 22 if you rely on standard access
# Port 2222
# Restrict to IPv4 if you don't use IPv6
# AddressFamily inet
Apply changes:
sshd -t # Test config syntax before restarting
sudo systemctl restart ssh # Ubuntu/Debian service name is "ssh", not "sshd"
Keep your existing session open while testing the new config in a second terminal. If SSH is broken you can still fix it from the live session.
Verify from a new terminal:
ssh -i ~/.ssh/yourserver_ed25519 adminuser@<server_ip>
# Must work
ssh -o PasswordAuthentication=yes adminuser@<server_ip>
# Should fail: Permission denied (publickey)
Layer 3: Firewall (UFW)
UFW is the standard firewall management tool for Ubuntu/Debian. It wraps iptables with sane defaults.
apt install -y ufw
# Set default policies: deny all incoming, allow all outgoing
ufw default deny incoming
ufw default allow outgoing
# Allow SSH — do this BEFORE enabling UFW or you'll lock yourself out
ufw allow ssh # allows port 22/tcp
# If you changed SSH port: ufw allow 2222/tcp
# Allow web traffic
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
# Allow WireGuard if applicable
# ufw allow 51820/udp
# Enable — applies rules immediately
ufw enable
# Status
ufw status verbose
Output of ufw status verbose should show:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
Rate limit SSH to slow brute-force attempts (allows 6 connections per 30 seconds per IP):
ufw limit ssh
Allow from specific IPs only (stricter):
ufw allow from 203.0.113.10 to any port 22 proto tcp
ufw delete allow ssh # Remove the wide-open rule
Layer 4: fail2ban
fail2ban monitors log files and bans IPs that show malicious behavior (too many failed logins, port scanning).
apt install -y fail2ban
Create a local jail config (never edit /etc/fail2ban/jail.conf directly — it gets overwritten on updates):
# /etc/fail2ban/jail.local
[DEFAULT]
# Ban for 1 hour
bantime = 3600
# Watch the last 10 minutes
findtime = 600
# Ban after 5 failures
maxretry = 5
# Send email on ban (optional — requires MTA)
# destemail = [email protected]
# action = %(action_mwl)s
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 86400 # 24 hours for SSH — more aggressive
[nginx-http-auth]
enabled = true
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 5
[nginx-limit-req]
enabled = true
filter = nginx-limit-req
logpath = /var/log/nginx/error.log
maxretry = 10
systemctl enable fail2ban
systemctl restart fail2ban
# Check status
fail2ban-client status
fail2ban-client status sshd
# Unban an IP (if you accidentally lock yourself out)
fail2ban-client set sshd unbanip <your_ip>
View currently banned IPs per jail:
fail2ban-client status sshd
# Output includes "Banned IP list" — that is the active ban list for the sshd jail
# Repeat for other jails: fail2ban-client status nginx-http-auth
Layer 5: Automatic Security Updates
Manual patching is a policy, not a system. unattended-upgrades installs security updates automatically.
apt install -y unattended-upgrades apt-listchanges
# Configure
dpkg-reconfigure -plow unattended-upgrades
# Answer "Yes" to enable automatic updates
Edit /etc/apt/apt.conf.d/50unattended-upgrades:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
// Automatically reboot at 3 AM if required (kernel updates)
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
// Remove unused packages after upgrade
Unattended-Upgrade::Remove-Unused-Dependencies "true";
// Send email on errors (requires MTA)
// Unattended-Upgrade::Mail "[email protected]";
// Unattended-Upgrade::MailOnlyOnError "true";
Edit /etc/apt/apt.conf.d/20auto-upgrades:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
Test it runs correctly:
unattended-upgrade --debug --dry-run
Layer 6: Kernel Hardening (sysctl)
The kernel has dozens of network and system parameters that affect security. Edit /etc/sysctl.d/99-hardening.conf:
# Prevent SYN flood attacks
net.ipv4.tcp_syncookies = 1
# Disable IP source routing (prevents routing attacks)
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# Ignore ICMP broadcast requests (Smurf attack mitigation)
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Ignore bogus ICMP error responses
net.ipv4.icmp_ignore_bogus_error_responses = 1
# Reverse path filtering — drops packets that don't match routing tables
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Log Martian packets (packets from impossible addresses)
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# Disable ICMP redirect acceptance
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
# Disable IPv6 router advertisements (unless needed)
net.ipv6.conf.all.accept_ra = 0
net.ipv6.conf.default.accept_ra = 0
# Protect against time-wait assassination
net.ipv4.tcp_rfc1337 = 1
# ASLR — Address Space Layout Randomization (should be 2 already; verify)
kernel.randomize_va_space = 2
# Restrict core dumps from setuid programs
fs.suid_dumpable = 0
# Restrict dmesg to root
kernel.dmesg_restrict = 1
# Restrict perf events to root
kernel.perf_event_paranoid = 3
# Hide kernel pointers in /proc
kernel.kptr_restrict = 2
Apply:
sysctl -p /etc/sysctl.d/99-hardening.conf
Layer 7: NGINX Hardening
NGINX's default config is minimal. Every production site needs security headers, TLS hardening, and rate limiting.
TLS Configuration
Generate certificates with Certbot (Let's Encrypt):
apt install -y certbot python3-certbot-nginx
certbot --nginx -d yourdomain.com -d www.yourdomain.com
Certbot auto-configures NGINX with TLS. Verify it's using good settings with ssl_labs.com/ssltest — aim for A+.
For manual TLS config in /etc/nginx/sites-available/yourdomain.conf:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# TLS 1.2 and 1.3 only — TLS 1.0/1.1 are deprecated and broken
ssl_protocols TLSv1.2 TLSv1.3;
# Strong cipher suites — ECDHE for forward secrecy
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers off; # Let clients choose from the list
# DH parameters for DHE cipher suites
ssl_dhparam /etc/nginx/dhparam.pem; # Generate: openssl dhparam -out /etc/nginx/dhparam.pem 2048
# HSTS — tells browsers to always use HTTPS for 1 year
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# OCSP Stapling — server fetches and caches the certificate validity status
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem;
resolver 1.1.1.1 9.9.9.9 valid=300s;
resolver_timeout 5s;
# Session resumption — reduces handshake overhead
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # Disable tickets (forward secrecy concern)
}
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://yourdomain.com$request_uri;
}
Security Headers
Add to the server block (HTTPS):
# Hide nginx version — prevents version-specific exploit targeting
server_tokens off;
# Prevent clickjacking — disallows iframe embedding
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent MIME sniffing — browser must respect Content-Type
add_header X-Content-Type-Options "nosniff" always;
# Content Security Policy — restrict what the page can load
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'" always;
# Referrer Policy — limit referrer info sent to external sites
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions Policy — disable browser features you don't use
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
Rate Limiting
Rate limiting prevents brute-force and basic DDoS. Add to the http block in nginx.conf:
http {
# Define a rate limit zone: 10MB of memory for IP tracking
# Rate: 10 requests per second per IP
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# For login endpoints: stricter
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
}
Apply to specific locations:
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://localhost:3000;
}
location /auth/login {
limit_req zone=login burst=5;
limit_req_status 429;
proxy_pass http://localhost:3000;
}
Test config before reload:
nginx -t
systemctl reload nginx
Layer 8: Docker Security
Default Docker containers run as root, have full filesystem access, and can escalate privileges. Harden each container.
Non-root user inside containers
In your Dockerfile:
FROM node:22-alpine
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm ci --only=production
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Docker daemon security (/etc/docker/daemon.json)
{
"icc": false,
"no-new-privileges": true,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
icc: false— disables inter-container communication by default; use Docker networks explicitlyno-new-privileges: true— prevents containers from gaining extra privileges via setuid
systemctl restart docker
docker-compose security flags
services:
app:
image: myapp:latest
# Drop ALL capabilities, add back only what's needed
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only if you bind to port <1024
# Prevent privilege escalation inside the container
security_opt:
- no-new-privileges:true
# Read-only root filesystem
read_only: true
# Writeable tmp if needed
tmpfs:
- /tmp
# Resource limits — prevent OOM/CPU starvation on other containers
# (In Compose without Swarm, use mem_limit and cpus instead of deploy.resources)
mem_limit: 512m
cpus: 0.5
# Health check
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
# Network isolation — only attach to named networks
networks:
- app_net
ports:
- "127.0.0.1:3000:3000" # Bind to loopback only; let NGINX proxy externally
networks:
app_net:
driver: bridge
Binding to 127.0.0.1:3000:3000 instead of 0.0.0.0:3000:3000 means the port is only accessible from the host (via NGINX proxy), not from the public internet — even if your firewall has a gap.
Scan images for vulnerabilities
# Trivy — install from official repo (not in default Ubuntu apt)
sudo apt install -y wget apt-transport-https gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update && sudo apt install -y trivy
trivy image myapp:latest
Layer 9: Log Management
What to monitor
/var/log/auth.log— all authentication events (SSH logins, sudo usage, su)/var/log/syslog— general system events/var/log/nginx/access.loganderror.log/var/log/ufw.log— blocked firewall traffic/var/log/fail2ban.log— bans and unbans- Docker container logs:
docker logs <container> -f
logrotate configuration
Prevent logs from filling the disk:
# /etc/logrotate.d/nginx-custom
/var/log/nginx/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 www-data adm
sharedscripts
postrotate
if [ -f /var/run/nginx.pid ]; then
kill -USR1 `cat /var/run/nginx.pid`
fi
endscript
}
Centralized logging (rsyslog → remote)
For multi-server environments, ship logs to a central server:
# /etc/rsyslog.d/99-remote.conf
*.* @@logserver.internal:514 # TCP (reliable)
Options: Graylog, Loki+Grafana, ELK Stack, or a managed service like Papertrail.
Layer 10: System Monitoring
Prometheus + Node Exporter
Node Exporter exposes CPU, memory, disk, network, and process metrics.
wget https://github.com/prometheus/node_exporter/releases/latest/download/node_exporter-1.8.2.linux-amd64.tar.gz
tar xvfz node_exporter-*.tar.gz
cp node_exporter-*/node_exporter /usr/local/bin/
Create a systemd service:
# /etc/systemd/system/node_exporter.service
[Unit]
Description=Prometheus Node Exporter
After=network.target
[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter \
--collector.systemd \
--collector.processes
Restart=on-failure
[Install]
WantedBy=multi-user.target
useradd -rs /bin/false node_exporter
systemctl daemon-reload
systemctl enable --now node_exporter
# Verify: curl http://localhost:9100/metrics
Bind node_exporter to localhost and restrict with UFW — never expose port 9100 publicly.
Netdata (simpler alternative)
Netdata provides a real-time dashboard with zero configuration:
wget -O /tmp/netdata-kickstart.sh https://my-netdata.io/kickstart.sh
sh /tmp/netdata-kickstart.sh --non-interactive
# Access at http://localhost:19999 (firewall to local access only)
Alerting
Configure Prometheus Alertmanager or Netdata cloud alerts for:
- Disk usage > 80%
- Memory usage > 90%
- CPU load average > (number of cores × 2)
- Service down (nginx, docker, application process)
- SSH login from unexpected IP
Layer 11: Backup Verification
A backup that has never been tested is not a backup.
restic
restic creates encrypted, deduplicated backups to any S3-compatible storage.
apt install restic
# Initialize a repository (S3 example)
export AWS_ACCESS_KEY_ID=<key>
export AWS_SECRET_ACCESS_KEY=<secret>
restic -r s3:s3.amazonaws.com/my-backup-bucket init
# Backup
restic -r s3:s3.amazonaws.com/my-backup-bucket backup /etc /var/www /home
# List snapshots
restic -r s3:s3.amazonaws.com/my-backup-bucket snapshots
# Restore (TEST THIS REGULARLY)
restic -r s3:s3.amazonaws.com/my-backup-bucket restore latest --target /tmp/restore-test
# Verify backup integrity
restic -r s3:s3.amazonaws.com/my-backup-bucket check
The key step most teams skip: restore test. Run restic restore to a temporary directory monthly and verify the files are intact.
What to back up
| Path | Contents | Frequency |
|---|---|---|
/etc | All system config | Daily |
/var/www | Web application files | Daily |
/home | User home directories | Daily |
/var/lib/docker/volumes | Docker persistent data | Daily |
| Database dumps | Run pg_dump/mysqldump before backup | Daily |
/etc/letsencrypt | TLS certificates | Daily |
Layer 12: Auditd (Compliance and Forensics)
auditd creates immutable audit logs of system calls — who ran what, when, and with what result.
apt install auditd
# Add rules to /etc/audit/rules.d/99-hardening.rules
-w /etc/passwd -p wa -k passwd_changes
-w /etc/shadow -p wa -k shadow_changes
-w /etc/sudoers -p wa -k sudoers_changes
-w /etc/ssh/sshd_config -p wa -k sshd_config
-a always,exit -F arch=b64 -S execve -k exec_commands
-w /var/log -p wa -k log_changes
augenrules --load
systemctl enable --now auditd
# Query audit logs
ausearch -k passwd_changes
ausearch -m USER_AUTH --success no # Failed login attempts
Layer 13: AppArmor
AppArmor (enabled by default on Ubuntu) confines programs to their declared behavior. Check status:
aa-status
# Should show profiles in enforce mode for nginx, mysqld, etc.
For custom applications, generate a profile:
apt install apparmor-utils
aa-genprof /path/to/your/app
# Run the app and perform typical operations, then:
aa-enforce /path/to/your/app
CIS Benchmark Audit
The CIS Ubuntu Linux Benchmark is the industry standard. Run Lynis for a quick audit:
apt install lynis
lynis audit system
Lynis scores 0–100. Aim for 70+ on a fresh server. It maps findings to CIS control numbers and explains each recommendation.
Complete Hardening Checklist
Initial Access
- Non-root user created, added to sudo
- Ed25519 SSH key pair generated (local machine)
- Public key copied to server, tested login
- Password SSH login tested to confirm it fails
SSH
-
PermitRootLogin no -
PasswordAuthentication no -
MaxAuthTries 3 -
AllowUsersrestricted to specific users -
ClientAliveInterval 300andClientAliveCountMax 2 -
sshd -tpasses before restart
Firewall
- UFW enabled with default deny incoming
- Only required ports open (22, 80, 443 minimum)
-
ufw limit sshapplied -
ufw status verbosereviewed
fail2ban
- Installed and enabled
-
/etc/fail2ban/jail.localcreated -
fail2ban-client status sshdshows active jail
Automatic Updates
-
unattended-upgradesinstalled and configured -
unattended-upgrade --dry-runpasses - Auto-reboot configured for your maintenance window
Kernel Parameters
-
/etc/sysctl.d/99-hardening.confcreated -
sysctl -papplied without errors
NGINX
-
server_tokens off - TLS 1.2/1.3 only
- HSTS header configured
- OCSP stapling enabled
- Security headers (X-Frame-Options, CSP, Referrer-Policy)
- Rate limiting for API and auth endpoints
Docker
- Containers run as non-root users
-
cap_drop: ALLwith specific capabilities added back -
no-new-privileges: true - Ports bound to
127.0.0.1(loopback) - Resource limits set
Logging
- logrotate configured
- Disk usage alerts set up
Monitoring
- node_exporter or Netdata running (localhost only)
- Alerts configured for disk, memory, CPU, service down
Backups
- restic or BorgBackup configured and running
- Restore test performed and documented
- Backup integrity check passes
Audit
- Lynis audit run, score noted
- auditd installed with basic rules
- AppArmor
aa-statusshows nginx in enforce mode
No checklist makes a machine “unhackable.” What this one does is move you off default-password SSH, unpatched kernels, and untested backups — onto ground where compromise costs effort and leaves traces worth investigating.
We build and hand over environments like this under Linux server setup: implemented, tested, and documented so your team can run day two without a dependency on magic commands in someone’s notes app.
Send a brief if you want a hardened host scoped for your stack — web, VPN, or VoIP.
Related reading
- WireGuard VPN production guide — remote access on top of a hardened Linux gateway
- WireGuard VPN setup services — peer configs, DNS, and runbooks when you want it implemented
- Business VoIP (FreePBX, Asterisk, 3CX) — when the same server tier also carries voice traffic