Fix deploy runbook against a real, already-crowded target host

Addresses a review of deploy/cloud_agent_deploy.md against Cloud-VM-1 (a
shared Hetzner VPS already running ~10 other services, including the
Forgejo instance this repo is hosted on):

- Blocking: git.cygnus.systems' git-SSH listens on port 2222, not 22 -
  fixed the SSH config example and clone fallback, with a note not to
  assume 22 if it changes again.
- High: container port now binds via configurable DAYBOOK_BIND_ADDR
  (defaults to all interfaces, set to 127.0.0.1 when a reverse proxy fronts
  it) instead of always publishing to 0.0.0.0; reverse-proxy+TLS is now the
  only path presented for internet-facing hosts, not a coequal option with
  direct exposure.
- High: replaced the Caddy-only hardening example with an Apache vhost +
  certbot path (matching how this specific host already terminates TLS for
  everything else on it), plus a check-before-assuming-80/443-are-free step.
- High: added mem_limit/mem_reservation to docker-compose.yml so this
  doesn't risk OOM-killing other services on a memory-tight box.
- Medium: added configurable DAYBOOK_HOST_DATA_DIR so persistent data can
  land on a separate/larger volume instead of a space-constrained main disk.
- Medium/Low: checklist now prompts checking the target's own
  service-inventory/monitoring/changelog conventions, and .env guidance
  notes it should stay low-stakes since a deploy agent reads/writes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-01 22:38:03 +01:00
parent d84753c839
commit 441d000d33
3 changed files with 92 additions and 20 deletions

View file

@ -35,10 +35,15 @@ a token tied to a full user account.
a Deploy Key on the repo in Forgejo (repo → Settings → Deploy Keys → a Deploy Key on the repo in Forgejo (repo → Settings → Deploy Keys →
read-only is sufficient), or hand it to them directly — never ask them to read-only is sufficient), or hand it to them directly — never ask them to
give you their personal account credentials. give you their personal account credentials.
3. Configure SSH to use it for this host: 3. Configure SSH to use it for this host. **Note:** this Forgejo's git-SSH
listens on port `2222`, not the default `22` (chosen so it doesn't clash
with the box's own system `sshd`) — connecting on `22` hits system SSH
instead and will fail or hang, not just warn. Don't assume `22` even if
this changes again later; confirm with the operator if unsure.
```bash ```bash
cat >> ~/.ssh/config <<'EOF' cat >> ~/.ssh/config <<'EOF'
Host git.cygnus.systems Host git.cygnus.systems
Port 2222
IdentityFile ~/.ssh/daybook_deploy IdentityFile ~/.ssh/daybook_deploy
IdentitiesOnly yes IdentitiesOnly yes
EOF EOF
@ -47,6 +52,8 @@ a token tied to a full user account.
```bash ```bash
ssh -T git@git.cygnus.systems ssh -T git@git.cygnus.systems
``` ```
If you'd rather not rely on `~/.ssh/config`, clone with the port explicit
instead: `ssh://git@git.cygnus.systems:2222/peter/daybook.git`.
If SSH access can't be arranged, fall back to an HTTPS access token the If SSH access can't be arranged, fall back to an HTTPS access token the
operator generates (Forgejo → user Settings → Applications → Generate New operator generates (Forgejo → user Settings → Applications → Generate New
@ -106,25 +113,48 @@ There is no shared API token to set — Daybook issues a personal token per
user from Settings → Account once a user exists (step 5). Never commit user from Settings → Account once a user exists (step 5). Never commit
`deploy/.env`; it is already gitignored. `deploy/.env`; it is already gitignored.
`deploy/.env` will be read and written by whatever agent runs this runbook.
Treat it as low-stakes: it should never hold anything more sensitive than
this app's own `DJANGO_SECRET_KEY`. If the target host has its own
documented convention for secrets (e.g. composed by reference at execution
time rather than written to a file), follow that instead — ask the operator
if unsure which applies here.
## 4. Network Access ## 4. Network Access
Cloud VMs default to a locked-down security group/firewall, unlike a home **If this VM is reachable from the public internet, a reverse proxy with TLS
LAN. Before starting the stack, open the port you'll serve on: in front of Daybook is not optional — it's the only path.** Bearer tokens are
this app's entire write-auth model, and they'd otherwise go over plaintext
HTTP straight to a port on the public IP. Direct port exposure (below) is
only for private/VPN-only networks with no public exposure.
- If exposing Daybook directly: open TCP `8000` to the addresses that should - **Internet-facing (the common case):** set `DAYBOOK_BIND_ADDR=127.0.0.1` in
reach it (ideally not `0.0.0.0/0` unless this is meant to be public). `deploy/.env` so the container port is not reachable from outside the VM at
- If putting a reverse proxy in front (recommended for anything all, then open `80`/`443` in the VM's firewall/security group for the
internet-facing — see §8): open `80`/`443` instead, and keep `8000` closed reverse proxy (see §8). Do not open `8000` externally.
to everything except `localhost`. - **Private/VPN-only network:** open TCP `8000` to the addresses that should
reach it (never `0.0.0.0/0`), and leave `DAYBOOK_BIND_ADDR` unset.
This is provider-specific (AWS security group, GCP firewall rule, DigitalOcean Before opening any port, check whether one is already open and in use —
cloud firewall, or the VM's own `ufw`/`iptables`). Ask the operator which `ss -tlnp | grep -E ':80|:443|:8000'` — rather than assuming a clean slate.
provider/console to use if you don't have API access to it yourself. Firewall/security-group configuration is provider-specific (AWS security
group, GCP firewall rule, DigitalOcean cloud firewall, or the VM's own
`ufw`/`iptables`). Ask the operator which provider/console to use if you
don't have API access to it yourself.
## 5. Build, Start, Create A User ## 5. Build, Start, Create A User
If the target has a separate, larger data volume than its main disk (common
on space-constrained VMs), point Daybook's persistent data at it before
starting: set `DAYBOOK_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`.
Ask the operator whether such a volume exists and where before defaulting to
the main disk — filling the main disk on a host running other services is a
bigger problem than it would be on a dedicated box. Create that same path
(the one you just set in `deploy/.env`, or `deploy/data` if you left it
unset) before starting the stack:
```bash ```bash
mkdir -p deploy/data mkdir -p deploy/data # or the DAYBOOK_HOST_DATA_DIR path if you set one above
docker compose -f deploy/docker-compose.yml up -d --build docker compose -f deploy/docker-compose.yml up -d --build
docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser
``` ```
@ -140,9 +170,13 @@ curl -fsS http://127.0.0.1:8000/api/capabilities
``` ```
A `403` with `"Login or a valid API token is required"` is the **expected** A `403` with `"Login or a valid API token is required"` is the **expected**
response when unauthenticated — it confirms the app is up. Log in at response when unauthenticated — it confirms the app is up. This `curl` runs
`http://<vm-ip-or-domain>:8000/` (or through your reverse proxy) with the against `127.0.0.1` from on the VM itself, so it works regardless of whether
superuser from step 5, grab a personal token from Settings → Account, then `DAYBOOK_BIND_ADDR` is set. Log in from a browser at whichever address is
actually externally reachable at this point in the runbook — `http://<vm-ip>:8000/`
if you're on the private/VPN-only path from §4, or `https://<domain>/` through
the reverse proxy once §8 is done if you're on the internet-facing path — with
the superuser from step 5, grab a personal token from Settings → Account, then
smoke-test a post: smoke-test a post:
```bash ```bash
@ -185,8 +219,8 @@ them — no need to back them up before a routine redeploy.
If this VM is reachable from the public internet (not just a private If this VM is reachable from the public internet (not just a private
network/VPN), do this before calling the deployment done: network/VPN), do this before calling the deployment done:
- Put a reverse proxy (Caddy, Nginx, or the cloud provider's load balancer) - Put a reverse proxy in front with TLS. Bearer tokens go over plaintext HTTP
in front with TLS. Bearer tokens go over plaintext HTTP otherwise. otherwise.
- Add the proxy's public hostname to `DJANGO_ALLOWED_HOSTS`. - Add the proxy's public hostname to `DJANGO_ALLOWED_HOSTS`.
- Keep `DJANGO_DEBUG=0` (already set above). - Keep `DJANGO_DEBUG=0` (already set above).
- Restrict SSH to key-based auth only; disable password login if you have - Restrict SSH to key-based auth only; disable password login if you have
@ -194,7 +228,30 @@ network/VPN), do this before calling the deployment done:
- Confirm the VM's firewall only allows the ports actually needed (80/443, - Confirm the VM's firewall only allows the ports actually needed (80/443,
and SSH from wherever the operator connects from). and SSH from wherever the operator connects from).
Caddy is the lowest-effort option (automatic TLS via Let's Encrypt): **Check for an existing reverse proxy before introducing a new one** —
`ss -tlnp | grep -E ':80|:443'`. A VM already running other services almost
always already has one (Apache, Nginx, Traefik) terminating TLS for
everything on it; a second process trying to bind 80/443 will just fail to
start, and stopping the existing proxy to "free the port" would take every
other service on the box down with it. If one exists, add a vhost to it
instead. Apache example:
```apache
<VirtualHost *:443>
ServerName daybook.example.com
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8000/
ProxyPassReverse / http://127.0.0.1:8000/
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/daybook.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/daybook.example.com/privkey.pem
</VirtualHost>
```
(`certbot --apache` will provision and wire up the certificate for you.)
Only reach for a standalone proxy like Caddy if the target genuinely has
nothing on 80/443 yet:
```text ```text
daybook.example.com { daybook.example.com {
@ -225,6 +282,9 @@ Before reporting the deployment complete, confirm all of these:
- [ ] A smoke-test artifact posts and renders successfully. - [ ] A smoke-test artifact posts and renders successfully.
- [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`. - [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`.
- [ ] A backup of `deploy/data/` has been taken and copied off the VM. - [ ] A backup of `deploy/data/` has been taken and copied off the VM.
- [ ] If the target host has its own service-inventory, monitoring, or
changelog conventions for new services, they've been followed — ask
the operator if unsure what those are.
Report back to the operator: the app URL, confirmation of the checklist Report back to the operator: the app URL, confirmation of the checklist
above, and the deploy SSH key fingerprint or token identity used (not the above, and the deploy SSH key fingerprint or token identity used (not the

View file

@ -7,10 +7,12 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
mem_limit: 384m
mem_reservation: 128m
ports: ports:
- "8000:8000" - "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000"
volumes: volumes:
- ./data:/data - ${DAYBOOK_HOST_DATA_DIR:-./data}:/data
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/capabilities"] test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/capabilities"]
interval: 30s interval: 30s

View file

@ -4,3 +4,13 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example
DAYBOOK_DATA_DIR=/data DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3 DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store ARTIFACTS_ROOT=/data/artifacts_store
# Optional, read by deploy/docker-compose.yml directly (not by the app):
# DAYBOOK_BIND_ADDR=127.0.0.1
# Uncomment and set to 127.0.0.1 when a reverse proxy on the same host will
# front this service — this stops the container port from being reachable
# directly from the network. Leave unset (binds all interfaces) only for
# private/VPN-only deployments with no proxy in front.
# DAYBOOK_HOST_DATA_DIR=/mnt/some-larger-volume/daybook-data
# Uncomment and point at a path on a separate/larger volume if the host's
# main disk is space-constrained. Defaults to ./data next to this file.