--- name: infra-deploy description: The release process for the beet website. Validate the same site across local, dev and prod in sequence with an identical verification pass, tearing dev down after. Use for any site deploy, teardown or deploy verification. Invariants live in crates/beet_infra/README.md. --- # Deploy The release process for the beet website. Validate the SAME site across three environments in sequence, each in its own sub-agent, each with an IDENTICAL verification pass: 1. **Local** proves the build (serve on localhost, verify, stop). 2. **Dev** proves the cloud path (deploy to `dev.beet.org`, verify, then tear down, since a standing dev environment is a real monthly cost). 3. **Prod** publishes the permanent production site (`beet.org` + `www.beet.org`). Run each step as a SEPARATE sub-agent. Do them in series: Dev only after Local is green, Prod only after Dev is green and torn down. > **Dev MUST be torn down after verification. Only prod stays up.** Dev is a temporary proving ground for the cloud path, not a standing environment: a live dev stack is a real recurring monthly cost. The moment dev verification is green (or fails, or is abandoned), run `just beet-destroy` and confirm no `beet-site--dev--*` S3 buckets, no dev Lightsail instance or static IP, and no `dev.beet.org` DNS record remain. Never leave dev running. ## Topology The deploy block is stage-aware (`LightsailBeetSiteBlock`, `crates/beet_extra/src/infra/templates.rs`). One `small_3_0` Lightsail box (2 GB, flat monthly price) per stage carries everything: no load balancer, no container registry, no ACM certificate, no VPC of its own. - **http**: Caddy on the box terminates TLS for every hostname with an automatic Let's Encrypt cert and reverse-proxies to the beet binary on its app port. Cloudflare fronts the site hostnames PROXIED (an `A` record at the static IP) and runs Full-strict, so the edge verifies Caddy's cert. The certificate store OUTLIVES the box: a `caddy-backup.timer` on the box saves `/var/lib/caddy` to `s3://beet-site----repo/caddy/` every 15 minutes and cloud-init restores it before Caddy starts, so a rebuilt box serves on the certificate the last one held (good for 90 days, renewed by Caddy a month early) and only the FIRST box of a stage ever issues at boot. The box's IAM key may write that prefix alone, never a release. - **ssh**: the beet TUI listens on port 22. Cloudflare does not proxy raw TCP, so ssh rides a DNS-ONLY `app.*` hostname pointing straight at the static IP. The box's own management sshd moves to **2222** (reachable with the stack's Lightsail key pair). Note this means the Lightsail browser console, which only dials 22, lands on the beet TUI rather than a shell. - **DNS** (stage-aware, and this is why `--stage` matters): `dev` publishes ONLY `dev.beet.org` (proxied) + `app.dev.beet.org` (DNS-only). `prod` publishes the apex `beet.org` + `www.beet.org` (proxied) + `app.beet.org` (DNS-only). A `dev` deploy can never touch production apex DNS. The generic `beet` binary reads the whole site from ONE store at runtime, the per-stage repo store `beet-site----repo` (the `` declaration in `site/main.bsx`), under a per-deploy prefix: `/repo/main.bsx`, `/repo/routes/`, `/repo/templates/` and `/repo/assets/` all live in it, mirrored from the staging dir `` assembles in `target/infra/beet-site/repo/`. The binary sits beside the document as `/bin/main-lightsail`, the ledger as `/ledger.json`, and `current/` holds the served ledger and the release pointers. The box learns which prefix to read from the release pointer at every start (`BEET_REPO` in `current/main-lightsail.env`), so a rollback moves the binary and its document together, and a content-only `sync` publishes into the prefix the running box already reads. No deployed binary reads `beet-site--shared--assets`; that bucket is the developers' source of record for `site/assets`, owned by `just site-shared pull|push` and untouched by any stage deploy. (The separate `beet--shared--assets` bucket backs the WORKSPACE `./assets` tree the examples, tests and wasm builds read; `just beet-shared pull|push`. The two trees are not mirrors: `blog/` and `branding/` belong to the site, and a few workspace-built files (the wasm binary, the geoip database, the robot faces) are borrowed by the `` manifest in `site/main.bsx`: into the staging dir under the deploy's ``, and into `site/assets` by the local `assets` verb.) The deployed binary is built with `--features aws_sdk,ssh,geoip` (see `` in `site/main.bsx`), so the served site includes the ssh terminal and country lookups. `aws_sdk` alone would serve http only. **The site entry declares its own infrastructure.** `site/main.bsx` is a one-shot `` dispatcher whose site is the `serve` route (the box launches `app --repo=s3://.. --server=http,ssh serve`, and a developer launches `beet --main=site serve --server=http`: one process, one argv, the same verb). Its raw analytics, rollup, and archive stores are declared ONCE under the stage `` as writable `` declarations: the deploy provisions them and runtime consumers reach them through the job's own fields, `AnalyticsRollupJob{raw: $analytics, rollups: $analytics, archive: $archive}`. Each composes `----