selfhostedops-stack/HARDENING.md

126 lines
4.1 KiB
Markdown

# ~$ 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
```bash
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
```
```bash
systemctl restart ssh
# TEST A NEW SSH SESSION BEFORE CLOSING THIS ONE.
```
## 2. Firewall — four ports, nothing else
```bash
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
```bash
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
```
## 4. fail2ban (ssh brute force)
```bash
apt install -y fail2ban # default ssh jail is enough to start
```
## 5. Vaultwarden specifics
- `VAULTWARDEN_SIGNUPS_ALLOWED=false` the 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)
```bash
# 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:
```yaml
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.