selfhostedops-stack/HARDENING.md
Maximilian Grimm d5398caece Episode 4: Vaultwarden single sign-on via Forgejo
- compose: SSO_ENABLED, SSO_AUTHORITY and the two client credentials, with the
  undocumented redirect path written down where people will look for it
- .env.example: was referenced by the README since episode 1 but never actually
  committed, so a fresh clone could not follow the quickstart
- README: the four steps, plus the debugging notes that cost the most time
  (no SSO line at startup, 404 on routes without query parameters, HEAD on the
  discovery endpoint, and the three-step member join)
- HARDENING: never copy db.sqlite3 alone while the container runs. WAL mode
  keeps recent writes in a side file, so that backup silently misses data
2026-08-26 21:54:23 +02:00

140 lines
4.8 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.
A plain one makes Vaultwarden print a NOTICE on every start. It is telling
the truth.
- **Never back up `db.sqlite3` on its own while the container runs.** The
database is in WAL mode: recent writes live in `db.sqlite3-wal` and are not
in the main file yet. Copy it alone and you get a backup that silently
misses your newest users and entries, and you find out on restore day.
Either stop the container first (see section 7), or take a consistent copy:
```bash
docker compose exec vaultwarden sh -c "sqlite3 /data/db.sqlite3 \".backup '/data/backup.sqlite3'\""
```
- Restore the backup somewhere else, once, before you trust it. A backup you
have never restored is a belief, not a backup.
- 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): 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.