Default-deny all inbound. Open only what is explicitly needed. This applies at the host firewall level regardless of what your cloud provider or router does.
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH — restrict to source IP when possible
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
nft add table inet filter
nft add chain inet filter input '{ type filter hook input priority 0; policy drop; }'
nft add rule inet filter input ct state established,related accept
nft add rule inet filter input iifname lo accept
nft add rule inet filter input tcp dport { 22, 80, 443 } accept
| Service | Correct exposure | Common mistake |
|---|---|---|
| SSH | Source-restricted or VPN only | Open to 0.0.0.0/0 |
| Database (Postgres, MySQL) | Localhost / internal VLAN only | Bound to 0.0.0.0 |
| Redis | Unix socket or 127.0.0.1 | No auth, public bind |
| Docker daemon (TCP) | Disabled entirely or mTLS | Port 2375 open, no auth |
| Admin panels (Grafana, etc.) | VPN or reverse-proxy with auth | Direct public exposure |
Rate-limit brute-force attempts at the firewall level. Install and enable the SSH jail at minimum.
# /etc/fail2ban/jail.local
[sshd]
enabled = true
maxretry = 5
bantime = 1h
findtime = 10m
SSH is the most targeted service on any public-facing server. Key-based authentication and a locked-down config eliminate the vast majority of attacks.
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
AllowUsers deploy # whitelist specific users
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
MaxAuthTries 3
LoginGraceTime 20s
ClientAliveInterval 300
ClientAliveCountMax 2
PermitRootLogin yes with password auth is default on many cloud images. Fix this before anything else.
Prefer ed25519. Accept ecdsa-sha2-nistp256 if required. Reject RSA below 4096 bits and all DSA keys.
ssh-keygen -t ed25519 -C "deploy@hostname"
Moving SSH off port 22 reduces log noise from automated scanners but is not a security control. Do it if helpful, but don't count on it for protection.
If password auth is needed for any reason, add TOTP via libpam-google-authenticator or pam_oath. Configure PAM before disabling the fallback path.
Every service should run as a dedicated user with no shell and no home directory write access beyond its working directory.
useradd --system --no-create-home --shell /usr/sbin/nologin myservice
# /etc/sudoers.d/deploy
# Allow specific commands, not NOPASSWD ALL
deploy ALL=(ALL) /bin/systemctl restart myservice, /bin/journalctl
deploy ALL=(ALL) NOPASSWD: ALL in sudoers nullifies the separation between your deploy user and root.
# List users with login shells
grep -v '/nologin\|/false' /etc/passwd
# Check sudo membership
getent group sudo wheel
600, owned by the service user755 directories, 644 files — never 777600, .ssh/ directory 700/usr/local/bin: 755, root-ownedUnpatched systems are the leading cause of compromise. Automate security updates; review and apply other updates on a fixed schedule.
apt install unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
dnf install dnf-automatic
# /etc/dnf/automatic.conf → upgrade_type = security
systemctl enable --now dnf-automatic-install.timer
Kernel patches require a reboot. Schedule maintenance windows or use livepatch (Ubuntu) / kpatch (RHEL) for critical production systems that can't tolerate downtime.
Track CVEs for every piece of software you self-host. Subscribe to upstream security mailing lists. Tools: trivy for container images, grype for SBOMs, osv-scanner for dependency manifests.
# Dockerfile
RUN adduser --disabled-password --no-create-home app
USER app
# docker-compose.yml
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /var/run
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # only if binding to port < 1024
/var/run/docker.sock) mounted into a container grants effective root on the host. Avoid it. Use a dedicated API proxy (e.g., docker-socket-proxy) if access is necessary.
networks:
frontend: # only app + reverse-proxy
backend: # only app + database
internal: true # no external connectivity
image@sha256:...)trivy image before deployment:latest in productionRun the Docker daemon itself as a non-root user. This limits blast radius if the daemon is compromised. Podman is rootless by default.
dockerd-rootless-setuptool.sh install
systemctl --user enable --now docker
Internal traffic on your homelab or VPN is not exempt. Use mTLS between services where feasible (Traefik + step-ca, Caddy with internal CA).
app.example.com {
reverse_proxy localhost:3000
tls {
protocols tls1.2 tls1.3
}
}
ssl_protocols TLSv1.2 TLSv1.3;
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;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
ssl_verify_peer=false everywhere defeats the purpose. Use a private CA via step-ca or cfssl for internal services so verification remains meaningful.
check_http or ssl_exporter with alerting600, readable only by the web server userSecrets in environment variables, plaintext files, or version control are the most common self-hosting security failure.
Database passwords, API keys, OAuth client secrets, TLS private keys, SMTP credentials, signing keys, encryption keys — anything that grants access or proves identity.
echo "s3cr3t" | docker secret create db_password -
# docker-compose.yml
services:
app:
secrets:
- db_password
secrets:
db_password:
external: true
# /etc/systemd/system/myservice.service
[Service]
LoadCredential=db_password:/etc/myservice/db_password
# Available at $CREDENTIALS_DIRECTORY/db_password at runtime
For multiple services or teams. Vault provides dynamic secrets, lease expiry, audit logs, and fine-grained ACLs. Run it with the Raft storage backend to avoid an external dependency.
# sops + age
age-keygen -o ~/.config/sops/age/keys.txt
sops --encrypt --age $(cat ~/.config/sops/age/keys.txt | grep public | awk '{print $NF}') .env > .env.enc
# Decrypt at deploy time in CI with the private key as a secret
.env files, private keys, or any credential to git — even in private repos. Rotate immediately if it happens.
Automate rotation where supported (database passwords, API tokens). For static secrets, enforce a maximum age and document rotation runbooks.
Logs you don't read are useless. Alerts you can't act on are noise. Build a minimal pipeline that surfaces real events.
auth.log / secureauth.logLogs stored only on the compromised host can be deleted by an attacker. Ship logs to a separate system immediately.
# rsyslog → remote syslog
*.* action(type="omfwd" target="10.0.0.5" port="514" protocol="tcp"
action.resumeRetryCount="-1"
queue.type="LinkedList" queue.saveOnShutdown="on")
# promtail config snippet
scrape_configs:
- job_name: system
static_configs:
- targets: [localhost]
labels:
job: varlogs
__path__: /var/log/*.log
| Event | Signal | Threshold |
|---|---|---|
| SSH brute force | Failed logins | > 10 in 5 min |
| SSH success from new IP | New source address | Any |
| Service down | systemd active state | failed |
| Disk full | filesystem usage | > 85% |
| TLS cert expiry | days_until_expiry | < 14 days |
| Unexpected sudo | sudo log line | Any non-whitelisted cmd |
auditctl -w /etc/passwd -p wa -k identity
auditctl -w /etc/sudoers -p wa -k sudoers
auditctl -w /root/.ssh -p wa -k ssh_root
Backups are the only recovery path for ransomware, accidental deletion, and hardware failure. The backup system must be isolated from the system it protects.
3 copies of data, on 2 different media types, with 1 copy off-site. A RAID array is not a backup.
restic init --repo sftp:user@backup-host:/backups/myserver
# Backup script (run via cron or systemd timer)
restic backup \
--repo sftp:user@backup-host:/backups/myserver \
--password-file /etc/restic/password \
/etc /home /var/lib/docker/volumes
# Prune policy
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
# PostgreSQL
pg_dump -U postgres mydb | gzip | restic backup --stdin \
--stdin-filename mydb.sql.gz
# MySQL
mysqldump --single-transaction mydb | gzip | restic backup --stdin \
--stdin-filename mydb.sql.gz
restic init + --append-only key) so a compromised server can't delete backupsMinimum acceptable posture for any internet-facing self-hosted server.
AllowUsers set0.0.0.0 unless intentionally publicauditd rules watching /etc/passwd, /etc/sudoers, and SSH keys● baseline ● recommended ● hardened