This isn’t a generic DigitalOcean checklist. Here I document the 14 hardening layers I apply to every infrastructure I build — from $5 VPSes to multi-service clusters. Each item comes with the actual code I use, explained.
If you have a server exposed to the internet, it’s already being scanned. Automated bots test SSH, open ports, and default configs 24/7. The question isn’t if you’ll be attacked — it’s when.
Legal Disclaimer: All content in this article is published exclusively for educational and defensive security purposes. Configurations have been anonymized. Use only on your own infrastructure or with explicit authorization.
1. SSH Hardening — Attack surface #1
SSH is the most exploited vector. OpenSSH’s default config is insecure by design — it allows root, passwords, forwarding, and weak ciphers.
# /etc/ssh/sshd_config
Port 2222 # Non-standard port kills 99% of bots
PermitRootLogin no # NEVER root via SSH
PasswordAuthentication no # Ed25519 keys only
PubkeyAuthentication yes
MaxAuthTries 3
MaxSessions 2
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers deploy # Explicit whitelist
# Modern ciphers — discard everything pre-2020
KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
# Disable everything unnecessary
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
Generate Ed25519 key with 100 KDF rounds
ssh-keygen -t ed25519 -a 100 -C "deploy@prod-$(date +%Y)"
ssh-copy-id -p 2222 deploy@your-server
# NEVER disconnect current session before testing
ssh -p 2222 deploy@your-server # new terminal window!
Real-time login alerts
Every SSH connection triggers a notification:
cat << 'SCRIPT' | sudo tee /usr/local/bin/login_alert.sh
#!/bin/bash
BODY="Login: $PAM_USER from $PAM_RHOST on $(hostname) at $(date)"
echo "$BODY" | mail -s "SSH Alert: $(hostname)" admin@yourdomain.com
# Webhook (Discord/Slack/Telegram)
curl -s -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"content\": \"$BODY\"}" >/dev/null 2>&1 &
SCRIPT
sudo chmod +x /usr/local/bin/login_alert.sh
echo "session optional pam_exec.so /usr/local/bin/login_alert.sh" \
| sudo tee -a /etc/pam.d/sshd
2. Firewall (UFW) — Deny all, allow explicit
sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 2222/tcp comment "SSH"
sudo ufw allow 80/tcp comment "HTTP"
sudo ufw allow 443/tcp comment "HTTPS"
sudo ufw limit 2222/tcp # Kernel-level brute force protection
sudo ufw --force enable
sudo ufw status verbose
Advanced iptables — conntrack + rate limiting
sudo iptables -A INPUT -p tcp --dport 2222 -m conntrack --ctstate NEW \
-m recent --set --name SSH
sudo iptables -A INPUT -p tcp --dport 2222 -m conntrack --ctstate NEW \
-m recent --update --seconds 60 --hitcount 6 --name SSH -j DROP
# Drop invalid packets
sudo iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
# Persist rules
sudo iptables-save | sudo tee /etc/iptables/rules.v4
3. Fail2Ban — Smart banning with UFW integration
sudo apt install fail2ban -y
cat << 'EOF' | sudo tee /etc/fail2ban/jail.local
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
banaction = ufw
ignoreip = 127.0.0.1/8
[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 86400
[nginx-http-auth]
enabled = true
port = http,https
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 5
[nginx-limit-req]
enabled = true
port = http,https
filter = nginx-limit-req
logpath = /var/log/nginx/error.log
maxretry = 10
findtime = 120
bantime = 7200
[nginx-botsearch]
enabled = true
port = http,https
filter = nginx-botsearch
logpath = /var/log/nginx/access.log
maxretry = 4
findtime = 60
bantime = 86400
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
4. Kernel Hardening — Production sysctl
Every parameter here has a reason. Not copy-paste — defense measures against specific attack classes:
cat << 'EOF' | sudo tee /etc/sysctl.d/99-hardening.conf
# Anti-Spoofing
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Source Routing (MUST disable)
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# ICMP Redirects (MitM vector)
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
# SYN Flood Protection
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
# Disable IPv6 if unused
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
# Log impossible packets
net.ipv4.conf.all.log_martians = 1
# Maximum ASLR
kernel.randomize_va_space = 2
# Restrict dmesg and kernel pointers
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
# Disable Magic SysRq
kernel.sysrq = 0
# Hardlinks/Symlinks protection
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
# Performance (web server bonus)
net.core.somaxconn = 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
EOF
sudo sysctl -p /etc/sysctl.d/99-hardening.conf
5. Systemd Service Hardening — Real sandboxing
Every service I run in production uses systemd sandboxing. Restart=always isn’t enough — you need to limit what the process can do:
# /etc/systemd/system/myapp.service
[Unit]
Description=Production API Service
After=network.target mongod.service redis-server.service
Requires=mongod.service redis-server.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/myapp
EnvironmentFile=/opt/myapp/.env
ExecStart=/opt/myapp/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
Restart=always
RestartSec=5
# === HARDENING (what 90% of people skip) ===
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/data
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
ProtectKernelModules=true
# Limits
LimitNOFILE=65535
LimitNPROC=4096
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
[Install]
WantedBy=multi-user.target
Multi-service orchestration check
for svc in myapp-api myapp-worker myapp-beat myapp-portal nginx mongod redis-server; do
STATUS=$(systemctl is-active $svc)
[ "$STATUS" = "active" ] && echo " ✓ $svc: ACTIVE" || echo " ✗ $svc: $STATUS"
done
6. Redis Hardening — Bind localhost + LRU
Redis should never be exposed:
REDIS_CONF="/etc/redis/redis.conf"
sudo sed -i 's/^bind .*/bind 127.0.0.1 ::1/' "$REDIS_CONF"
sudo sed -i 's/^daemonize no/daemonize yes/' "$REDIS_CONF"
echo "maxmemory 256mb" | sudo tee -a "$REDIS_CONF"
echo "maxmemory-policy allkeys-lru" | sudo tee -a "$REDIS_CONF"
# Disable dangerous commands
echo 'rename-command FLUSHDB ""' | sudo tee -a "$REDIS_CONF"
echo 'rename-command FLUSHALL ""' | sudo tee -a "$REDIS_CONF"
echo 'rename-command CONFIG ""' | sudo tee -a "$REDIS_CONF"
echo 'rename-command DEBUG ""' | sudo tee -a "$REDIS_CONF"
sudo systemctl restart redis-server
7. MongoDB Hardening — Auth + Bind + Network
cat << 'EOF' | sudo tee /etc/mongod.conf
storage:
dbPath: /var/lib/mongodb
journal:
enabled: true
systemLog:
destination: file
logAppend: true
path: /var/log/mongodb/mongod.log
net:
port: 27017
bindIp: 127.0.0.1 # NEVER 0.0.0.0
security:
authorization: enabled # Mandatory auth
EOF
sudo systemctl restart mongod
8. Auditing with auditd — Who did what
sudo apt install auditd -y
cat << 'EOF' | sudo tee /etc/audit/rules.d/hardening.rules
-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/ssh/sshd_config -p wa -k sshd_config
-w /etc/crontab -p wa -k cron
-w /var/spool/cron/ -p wa -k cron
-a always,exit -F arch=b64 -S execve -F euid=0 -k root_commands
-w /sbin/insmod -p x -k kernel_modules
-w /sbin/modprobe -p x -k kernel_modules
-w /etc/hosts -p wa -k network
-w /etc/nginx/ -p wa -k nginx_config
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/systemctl -k systemd
EOF
sudo systemctl restart auditd
9. Postfix Secure Relay — SASL + mandatory TLS
# /etc/postfix/main.cf
smtpd_banner = $myhostname ESMTP
smtp_tls_security_level = encrypt
smtp_tls_wrappermode = yes
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
relayhost = [smtp.provider.com]:465
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
echo "[smtp.provider.com]:465 user@domain.com:password" | sudo tee /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
sudo chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
sudo systemctl restart postfix
10. Automated Setup — One-shot provisioning
#!/bin/bash
set -euo pipefail
apt-get update && apt-get install -y \
curl wget git build-essential certbot python3-certbot-nginx \
logrotate fail2ban auditd ufw
# Node.js 22 LTS, MongoDB 8.0, Redis, Nginx
# Python venv with auto-generated secrets
SECRET_KEY=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 32)
# Deploy systemd services and enable
systemctl daemon-reload
for svc in myapp-api myapp-worker myapp-beat; do
systemctl enable --now "$svc"
done
11. Automated Backup with Google Drive
Daily backup at 3 AM with 30-day retention:
echo "0 3 * * * /opt/myapp/backup.sh --quiet >> /var/log/backup.log 2>&1" \
| sudo crontab -
# What the script does:
# 1. mongodump → /tmp/backup/mongodb/
# 2. redis-cli BGSAVE → copy RDB
# 3. Copy configs: .env, nginx.conf, systemd units
# 4. rsync source code (--exclude __pycache__, node_modules, .git)
# 5. Generate metadata JSON with versions and timestamps
# 6. tar.gz with timestamp
# 7. Upload to Google Drive via API
# 8. Remove local backups > 30 days
Selective restore
./restore.sh --from /backups/backup-2026-03-08.tar.gz
./restore.sh --skip-code --skip-redis # database and configs only
./restore.sh --skip-mongo --skip-config # code only
12. Logrotate — Keep disk clean
cat << 'EOF' | sudo tee /etc/logrotate.d/myapp
/var/log/myapp*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 root root
}
EOF
13. Active Monitoring
#!/bin/bash
# /usr/local/bin/health_check.sh — runs via cron every 5min
FAILED=""
for svc in nginx mongod redis-server myapp-api; do
if ! systemctl is-active --quiet "$svc"; then
FAILED+="$svc "
systemctl restart "$svc"
fi
done
for PORT in 80 443 8000 27017 6379; do
ss -tlnp | grep -q ":$PORT " || FAILED+="port:$PORT "
done
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8000/api/health")
[ "$CODE" != "200" ] && FAILED+="http:health($CODE) "
redis-cli ping | grep -q PONG || FAILED+="redis-ping "
[ -n "$FAILED" ] && echo "FAIL: $FAILED" | mail -s "ALERT: $(hostname)" admin@yourdomain.com
14. Safe cleanup before reprovisioning
#!/bin/bash
set -euo pipefail
for svc in myapp-api myapp-worker myapp-beat mongod redis-server nginx; do
systemctl stop "$svc" 2>/dev/null || true
systemctl disable "$svc" 2>/dev/null || true
done
# Preserve Let's Encrypt certs (avoid rate limits)
mkdir -p /tmp/letsencrypt-saved
cp -a /etc/letsencrypt/live /tmp/letsencrypt-saved/ 2>/dev/null || true
cp -a /etc/letsencrypt/archive /tmp/letsencrypt-saved/ 2>/dev/null || true
apt-get purge -y nginx* mongodb* redis* || true
apt-get autoremove -y
rm -rf /var/lib/mongodb /var/log/mongodb /var/lib/redis /var/log/redis
rm -f /etc/systemd/system/myapp-*.service
systemctl daemon-reload
Final checklist — 14 layers
| # | Layer | Status |
|---|---|---|
| 1 | SSH: custom port, root off, Ed25519 only, modern ciphers | ☐ |
| 2 | UFW: deny all, allow explicit, limit SSH | ☐ |
| 3 | Fail2Ban: SSH 24h ban, nginx filters, UFW integration | ☐ |
| 4 | Kernel: sysctl anti-spoof, SYN flood, ASLR, kptr_restrict | ☐ |
| 5 | Systemd: sandbox with NoNewPrivileges, ProtectSystem, PrivateTmp | ☐ |
| 6 | Redis: bind 127.0.0.1, LRU, dangerous commands disabled | ☐ |
| 7 | MongoDB: auth enabled, bind localhost | ☐ |
| 8 | Auditd: identity, SSH config, root commands, kernel modules | ☐ |
| 9 | Postfix: SASL + mandatory TLS, restricted relay | ☐ |
| 10 | Automated setup: one-shot script, generated secrets | ☐ |
| 11 | Backup: mongodump, Redis RDB, configs, Google Drive, 30d retention | ☐ |
| 12 | Logrotate: daily rotation, compression, 14 days | ☐ |
| 13 | Monitoring: health check, disk/mem alerts, auto-restart | ☐ |
| 14 | Cleanup: preserve certs, full purge, reprovisioning ready | ☐ |
Conclusion
Security is infrastructure as code. Each layer here is a script that can be versioned, tested, and replicated. The goal isn’t perfection — it’s reducing the attack surface to an acceptable minimum and having visibility into what happens on your server.
The difference between a “configured” server and a hardened server is in the details: systemd sandboxing, Redis with renamed commands, auditd monitoring every execve as root, backups that actually work when you need them.
This article documents defensive security practices applied to real infrastructure. All configurations have been anonymized. Use these techniques only on your own servers or with authorization.
Rafael Cavalcanti da Silva — Fullstack Developer & Security Specialist rafaelroot.com