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 →
read-only is sufficient), or hand it to them directly — never ask them to
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
cat >> ~/.ssh/config <<'EOF'
Host git.cygnus.systems
Port 2222
IdentityFile ~/.ssh/daybook_deploy
IdentitiesOnly yes
EOF
@ -47,6 +52,8 @@ a token tied to a full user account.
```bash
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
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
`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
Cloud VMs default to a locked-down security group/firewall, unlike a home
LAN. Before starting the stack, open the port you'll serve on:
**If this VM is reachable from the public internet, a reverse proxy with TLS
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
reach it (ideally not `0.0.0.0/0` unless this is meant to be public).
- If putting a reverse proxy in front (recommended for anything
internet-facing — see §8): open `80`/`443` instead, and keep `8000` closed
to everything except `localhost`.
- **Internet-facing (the common case):** set `DAYBOOK_BIND_ADDR=127.0.0.1` in
`deploy/.env` so the container port is not reachable from outside the VM at
all, then open `80`/`443` in the VM's firewall/security group for the
reverse proxy (see §8). Do not open `8000` externally.
- **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
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.
Before opening any port, check whether one is already open and in use —
`ss -tlnp | grep -E ':80|:443|:8000'` — rather than assuming a clean slate.
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
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
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 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**
response when unauthenticated — it confirms the app is up. Log in at
`http://<vm-ip-or-domain>:8000/` (or through your reverse proxy) with the
superuser from step 5, grab a personal token from Settings → Account, then
response when unauthenticated — it confirms the app is up. This `curl` runs
against `127.0.0.1` from on the VM itself, so it works regardless of whether
`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:
```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
network/VPN), do this before calling the deployment done:
- Put a reverse proxy (Caddy, Nginx, or the cloud provider's load balancer)
in front with TLS. Bearer tokens go over plaintext HTTP otherwise.
- Put a reverse proxy in front with TLS. Bearer tokens go over plaintext HTTP
otherwise.
- Add the proxy's public hostname to `DJANGO_ALLOWED_HOSTS`.
- Keep `DJANGO_DEBUG=0` (already set above).
- 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,
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
daybook.example.com {
@ -225,6 +282,9 @@ Before reporting the deployment complete, confirm all of these:
- [ ] A smoke-test artifact posts and renders successfully.
- [ ] 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.
- [ ] 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
above, and the deploy SSH key fingerprint or token identity used (not the

View file

@ -7,10 +7,12 @@ services:
restart: unless-stopped
env_file:
- .env
mem_limit: 384m
mem_reservation: 128m
ports:
- "8000:8000"
- "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000"
volumes:
- ./data:/data
- ${DAYBOOK_HOST_DATA_DIR:-./data}:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/capabilities"]
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
DATABASE_PATH=/data/db.sqlite3
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.