Security is not a feature you add later — it's a practice woven into every layer. This chapter covers the essential security measures for any production website.
# Force browsers to always use HTTPS (add to Nginx/response headers)
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
# Once set, browsers will NEVER make HTTP requests to your domain
# Submit to HSTS preload list: https://hstspreload.org/
Firewall Configuration
# UFW (Uncomplicated Firewall) — Ubuntu/Debian
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH (or your custom port)
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
sudo ufw status verbose
# iptables (lower level, more control)
# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
# Allow SSH, HTTP, HTTPS
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
# Drop everything else
iptables -A INPUT -j DROP
# Save rules (persist across reboot)
sudo apt install iptables-persistent
sudo netfilter-persistent save
SSH Hardening
# /etc/ssh/sshd_config — Production SSH configuration
Port 2222 # Non-standard port (reduces noise)
PermitRootLogin no # Never allow root SSH
PasswordAuthentication no # Keys only
PubkeyAuthentication yes
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers deploy # Whitelist specific users
Protocol 2
# Optional: Restrict to specific IPs (if you have static IP)
# AllowUsers deploy@YOUR_IP
Security Headers
# Add to Nginx server block or application responses:
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
# Test your headers: https://securityheaders.com/
Secrets Management
Never store secrets in: Git repositories, Dockerfiles, environment variables in CI logs, plain text config files committed to version control. Even if the repo is private — credentials in Git history are permanent.
Tool
Type
Best For
Environment variables
Runtime injection
Simple apps (loaded from secure source)
AWS Secrets Manager
Cloud-managed
AWS workloads, auto-rotation
HashiCorp Vault
Self-hosted/cloud
Multi-cloud, dynamic secrets, PKI
SOPS
Encrypted files in Git
GitOps workflows, small teams
Doppler / 1Password
SaaS
Teams wanting simple UI
# Example: Using SOPS to encrypt secrets in Git
# Install: brew install sops age
# Generate an age key
age-keygen -o keys.txt
# Public key: age1xxxxxxx...
# Create .sops.yaml in repo root
creation_rules:
- path_regex: \.enc\.yaml$
age: age1xxxxxxx...
# Encrypt a secrets file
sops --encrypt secrets.yaml > secrets.enc.yaml
# secrets.enc.yaml is safe to commit — encrypted at rest
# Decrypt at deploy time
sops --decrypt secrets.enc.yaml > /etc/myapp.env
Backup Strategy — The 3-2-1 Rule
3 copies of your data
2 different storage media/types
1 copy offsite (different geographic location)
# Automated database backup script
#!/bin/bash
# /opt/scripts/backup-db.sh
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/backups"
S3_BUCKET="s3://myapp-backups-prod"
# Dump database
pg_dump -h localhost -U myapp myapp_prod | gzip > "$BACKUP_DIR/db_$TIMESTAMP.sql.gz"
# Upload to S3 (offsite copy)
aws s3 cp "$BACKUP_DIR/db_$TIMESTAMP.sql.gz" "$S3_BUCKET/db/$TIMESTAMP.sql.gz"
# Retain only last 7 local backups
ls -t $BACKUP_DIR/db_*.sql.gz | tail -n +8 | xargs rm -f
# Cron: Run daily at 3 AM
# 0 3 * * * /opt/scripts/backup-db.sh >> /var/log/backup.log 2>&1
Test your backups! A backup you've never restored is not a backup — it's a hope. Schedule monthly restore tests to a separate environment. Automate this if possible.