4.1 KiB
~$ Hardening checklist
This is the non-negotiable baseline before real company data touches the box. Every item is copy-paste ready for Debian/Ubuntu.
1. SSH: keys only, no root
adduser ops && usermod -aG sudo,docker ops
mkdir -p /home/ops/.ssh && cp ~/.ssh/authorized_keys /home/ops/.ssh/ \
&& chown -R ops:ops /home/ops/.ssh && chmod 700 /home/ops/.ssh
/etc/ssh/sshd_config.d/hardening.conf:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
systemctl restart ssh
# TEST A NEW SSH SESSION BEFORE CLOSING THIS ONE.
2. Firewall — four ports, nothing else
apt install -y ufw
ufw default deny incoming
ufw allow 80/tcp 443/tcp 443/udp # caddy (http/https/h3)
ufw allow 2222/tcp # git ssh
ufw allow 51820/udp # wireguard (only if using --profile vpn)
ufw allow 22/tcp # your ssh (move it if you want)
ufw enable
Note: Docker's published ports bypass ufw INPUT rules — that's exactly why this
compose file publishes only caddy, git-ssh and wireguard, and keeps postgres/redis
on the internal network with no ports at all. Don't add ports: to internal
services "for debugging" and forget them.
3. Automatic security updates
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
4. fail2ban (ssh brute force)
apt install -y fail2ban # default ssh jail is enough to start
5. Vaultwarden specifics
VAULTWARDEN_SIGNUPS_ALLOWED=falsethe moment your team is on board.- Use an argon2
ADMIN_TOKEN(vaultwarden hash/ see wiki), not a plain string. - The admin panel (
/admin) is for setup — consider blocking it in Caddy afterwards:
vault.{$DOMAIN} {
@admin path /admin*
respond @admin 404
reverse_proxy vaultwarden:80
}
6. Pin your images
latest is fine on day one, dangerous on day 300. Pin majors at minimum
(caddy:2-alpine, forgejo:11, uptime-kuma:1 already are). Watchtower blindly
auto-updating a password manager at 3 a.m. is how you get incidents — update
deliberately: docker compose pull && docker compose up -d after reading release notes.
n8n and Outline move fast; Vaultwarden occasionally has breaking web-vault pairs.
7. Backups (minimum viable, today)
# stop, snapshot volumes, start — cron it nightly until Episode 10 (borgmatic offsite)
docker compose stop
tar czf /backup/stack-$(date +%F).tgz -C /var/lib/docker/volumes .
docker compose start
Plus: enable your provider's server snapshots. Two clicks, saves your company.
8. What this list is NOT
Not covered here, on purpose (own episodes): SSO everywhere, 2FA enforcement per service, Netbird zero-trust mesh, central logging, CrowdSec, offsite borg. Baseline first. Perfect later.
CI: the two ways to hand your server away
Both of these look like shortcuts and both give anyone who can push a branch full control of the machine.
1. Mounting the host Docker socket into the runner. Every forum answer suggests it, because it works instantly:
volumes:
- /var/run/docker.sock:/var/run/docker.sock # do not do this
A job can then start a privileged container on the host, mount / and read every
volume on the box, including the password manager sitting in the next container.
Use the docker-in-docker sidecar in compose.yml instead. It costs six lines and
the jobs talk to a daemon that has nothing of yours in it.
2. An unrestricted SSH key for the deploy step. If the key in your CI secrets
can run any command, the pipeline is a root shell with extra steps. Lock it to one
command in authorized_keys:
command="/usr/local/bin/deploy-myapp",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA...
The script receives the argument from the job in $SSH_ORIGINAL_COMMAND. Sanitise
it — the example in examples/deploy-hello-ops.sh strips everything that is not a
hex character, because it only ever expects an image tag. The deploy user needs
Docker access, so the forced command is the thing containing it; without the forced
command, membership in the docker group is equivalent to root.