Self-Hosting security reference

01 — Network perimeter

Network Perimeter

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 (Debian/Ubuntu)

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

nftables (preferred on modern kernels)

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
WARN Cloud security groups are not a substitute for host-level firewalls. If you misconfigure or migrate the instance, the host firewall is your last line of defense.

Port exposure decisions

ServiceCorrect exposureCommon mistake
SSHSource-restricted or VPN onlyOpen to 0.0.0.0/0
Database (Postgres, MySQL)Localhost / internal VLAN onlyBound to 0.0.0.0
RedisUnix socket or 127.0.0.1No auth, public bind
Docker daemon (TCP)Disabled entirely or mTLSPort 2375 open, no auth
Admin panels (Grafana, etc.)VPN or reverse-proxy with authDirect public exposure

Fail2ban

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
02 — SSH

SSH Hardening

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.

/etc/ssh/sshd_config — mandatory changes

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
BAD PermitRootLogin yes with password auth is default on many cloud images. Fix this before anything else.

Key types

Prefer ed25519. Accept ecdsa-sha2-nistp256 if required. Reject RSA below 4096 bits and all DSA keys.

ssh-keygen -t ed25519 -C "deploy@hostname"

Non-standard port

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.

Two-factor via TOTP

If password auth is needed for any reason, add TOTP via libpam-google-authenticator or pam_oath. Configure PAM before disabling the fallback path.

03 — Users & privileges

User & Privilege Model

Principle of least privilege

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

sudo configuration

# /etc/sudoers.d/deploy
# Allow specific commands, not NOPASSWD ALL
deploy ALL=(ALL) /bin/systemctl restart myservice, /bin/journalctl
BAD deploy ALL=(ALL) NOPASSWD: ALL in sudoers nullifies the separation between your deploy user and root.

Audit existing accounts

# List users with login shells
grep -v '/nologin\|/false' /etc/passwd

# Check sudo membership
getent group sudo wheel

File permissions

04 — Patch management

Patch Management

Unpatched systems are the leading cause of compromise. Automate security updates; review and apply other updates on a fixed schedule.

Unattended upgrades (Debian/Ubuntu)

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-automatic (RHEL/Fedora)

dnf install dnf-automatic
# /etc/dnf/automatic.conf → upgrade_type = security
systemctl enable --now dnf-automatic-install.timer

Kernel updates & reboots

Kernel patches require a reboot. Schedule maintenance windows or use livepatch (Ubuntu) / kpatch (RHEL) for critical production systems that can't tolerate downtime.

Application-layer updates

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.

05 — Container isolation

Container Isolation

Run as non-root inside the container

# Dockerfile
RUN adduser --disabled-password --no-create-home app
USER app

Read-only filesystem

# docker-compose.yml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

Drop Linux capabilities

security_opt:
  - no-new-privileges:true
cap_drop:
  - ALL
cap_add:
  - NET_BIND_SERVICE   # only if binding to port < 1024
WARN The Docker socket (/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.

Network segmentation

networks:
  frontend:   # only app + reverse-proxy
  backend:    # only app + database
    internal: true   # no external connectivity

Image hygiene

Rootless Docker / Podman

Run 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
06 — TLS & certificates

TLS & Certificates

Always use TLS, even internally

Internal traffic on your homelab or VPN is not exempt. Use mTLS between services where feasible (Traefik + step-ca, Caddy with internal CA).

Caddy — automatic TLS with ACME

app.example.com {
    reverse_proxy localhost:3000
    tls {
        protocols tls1.2 tls1.3
    }
}

Nginx — hardened TLS config

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;
BAD Self-signed certs with ssl_verify_peer=false everywhere defeats the purpose. Use a private CA via step-ca or cfssl for internal services so verification remains meaningful.

Certificate management

07 — Secrets management

Secrets Management

Secrets in environment variables, plaintext files, or version control are the most common self-hosting security failure.

What counts as a secret

Database passwords, API keys, OAuth client secrets, TLS private keys, SMTP credentials, signing keys, encryption keys — anything that grants access or proves identity.

Docker secrets (Swarm)

echo "s3cr3t" | docker secret create db_password -

# docker-compose.yml
services:
  app:
    secrets:
      - db_password
secrets:
  db_password:
    external: true

Systemd credentials

# /etc/systemd/system/myservice.service
[Service]
LoadCredential=db_password:/etc/myservice/db_password
# Available at $CREDENTIALS_DIRECTORY/db_password at runtime

HashiCorp Vault (self-hosted)

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.

Encrypted .env files

# 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
BAD Never commit .env files, private keys, or any credential to git — even in private repos. Rotate immediately if it happens.

Rotation

Automate rotation where supported (database passwords, API tokens). For static secrets, enforce a maximum age and document rotation runbooks.

08 — Logging & alerting

Logging & Alerting

Logs you don't read are useless. Alerts you can't act on are noise. Build a minimal pipeline that surfaces real events.

What to log

Centralize logs off-host

Logs 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")

Grafana Loki stack (self-hosted)

# promtail config snippet
scrape_configs:
  - job_name: system
    static_configs:
      - targets: [localhost]
        labels:
          job: varlogs
          __path__: /var/log/*.log

Alerting rules to write first

EventSignalThreshold
SSH brute forceFailed logins> 10 in 5 min
SSH success from new IPNew source addressAny
Service downsystemd active statefailed
Disk fullfilesystem usage> 85%
TLS cert expirydays_until_expiry< 14 days
Unexpected sudosudo log lineAny non-whitelisted cmd

Audit daemon

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
09 — Backup strategy

Backup Strategy

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-2-1 rule

3 copies of data, on 2 different media types, with 1 copy off-site. A RAID array is not a backup.

Restic — encrypted, deduplicated backups

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

Database backups

# 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
WARN Test restores on a schedule. A backup you've never restored from is an assumption, not a guarantee.

Backup access model

10 — Baseline checklist

Baseline Checklist

Minimum acceptable posture for any internet-facing self-hosted server.

baseline    recommended    hardened