diff --git a/.claude/skills/handoff-report/SKILL.md b/.claude/skills/handoff-report/SKILL.md
new file mode 100644
index 0000000..046dee4
--- /dev/null
+++ b/.claude/skills/handoff-report/SKILL.md
@@ -0,0 +1,325 @@
+---
+name: handoff-report
+description: >-
+ Transfer working knowledge of an existing part of The Commons to someone who lacks it —
+ what it does and who depends on it, Mermaid diagrams of real behaviour, data-model/interface
+ tables, and the sharp edges that bite newcomers. Triggers: "how does X work", "document this
+ service", "onboard someone onto Y", "I'm handing this off", "explain this subsystem",
+ "write a handoff doc".
+---
+
+# Handoff Report
+
+## Overview
+
+A handoff report transfers working knowledge of a system that already exists to someone who
+has to reason about it without you — a newcomer, an inheriting owner, a future maintainer of
+this repo who wasn't around when it was built.
+
+The format is deliberately narrow: prose for why it exists, diagrams for how it behaves,
+tables for surface area, and an explicit list of what will surprise you. Each carries what the
+others can't.
+
+Two principles do most of the work:
+
+- **Describe behaviour, not intent.** Read the code and say what it does. A report that
+ documents what the system was supposed to do is worse than no report — the reader trusts it
+ and debugs against a fiction.
+- **The report stands completely alone.** Every reference pointing outside the document is a
+ place the reader stops and doesn't come back.
+
+## When to use
+
+- Onboarding someone onto a service, subsystem, or domain in this repo (`ingestion/`,
+ `broadcast/`, the auth bridge, the devtools monitor, etc.).
+- Handing off ownership — leaving the project, rotating, going on leave. (This is the sole-dev
+ → future-team case this skill exists for.)
+- A neighbouring workstream needs to integrate and keeps asking how something works.
+- Knowledge exists only in one session's context, one PR description, or one person's head.
+- Post-hoc documentation of something built fast (a suite pushed in one sitting) and never
+ written down properly.
+
+**Not for:** point-in-time session status updates — this repo's `docs/handoff-suite-*.md` files
+and `notion-sync/STATE.md` are that genre (what shipped, what's uncommitted, what to pick up
+next). A handoff-report is durable subsystem knowledge, not a snapshot of where a session left
+off. Also not for planning work that hasn't been built yet — use `/write-tickets` for that.
+
+## Where the finished report lands
+
+`docs/` is the system of record agents read on every task (per `CLAUDE.md`) — keep it lean and
+agent-oriented, don't add human-onboarding material to it. A handoff report is written *for a
+person*, so it lands in **`human-docs/`** instead:
+
+1. Write it to `human-docs/.md` (e.g. `human-docs/ingestion-pipeline.md` — descriptive
+ name, no suite number or date unless the report is itself dated point-in-time knowledge).
+2. Add a row to the table in `human-docs/README.md` — Doc / Purpose / Written — so it's
+ discoverable. An undiscoverable handoff report is as good as not written.
+3. If `docs/*.md` or a per-directory `AGENTS.md` already covers this ground for agents, don't
+ fork a duplicate — the handoff report can reference and go beyond it (more narrative context,
+ the sharp edges an agent doc wouldn't include), but state at the top which `docs/` file it
+ complements so the two don't drift into contradicting each other.
+
+If the report is a one-off session status note instead (not durable subsystem knowledge), it
+follows the `docs/handoff-suite-N-*.md` pattern instead of this skill's structure — that's
+agent/continuation-facing state, not human onboarding, and it stays in `docs/`.
+
+## Grounding — the part that makes it worth reading
+
+Everything in the report is a claim about a system that exists, so everything is checkable.
+Check it.
+
+- **Read the source before writing the section.** Models in `models.py` for the data model,
+ `urls.py`/views for interfaces, the actual task/handler for a flow. Never reconstruct
+ behaviour from a ticket, a PR description, a commit message, or another doc — per
+ `CLAUDE.md`, if a doc contradicts the code, trust the code and flag the drift.
+- **Point at the module, never the line.** Name the file, function, or component —
+ `ingestion/services.py`, "the `before_save` hook on `StagedEvent`" — so the reader knows
+ where to look. Never `services.py:88`. Line numbers are wrong within a week of anyone
+ touching the file, and a citation that's confidently wrong is worse than none: the reader
+ lands somewhere unrelated and stops trusting the rest.
+- **Explain the idea, don't index the code.** The value is in what the module does and why, at
+ a level that survives refactoring. If a claim can only be supported by pointing at an exact
+ line, it's too fine-grained for a handoff report — the reader needs the shape, the code shows
+ the detail.
+- **Say what you didn't verify.** "I couldn't find where this is cleaned up" is useful. A
+ confident guess in the same sentence style as verified facts is not.
+- **Date it and name what it reflects** — a commit hash (`git rev-parse HEAD`), a release, or
+ just the date. A context-transfer doc is read long after it's written; the reader needs to
+ know how much drift to expect.
+- **Prefer the system's own vocabulary.** If the code calls it a `StagedEvent`, don't call it a
+ "pending record" because it reads better. If it's a "source", don't call it a "feed" unless
+ the code does.
+
+## Structure
+
+Use the sections that carry weight; drop the rest and renumber contiguously.
+
+1. **What this is and who depends on it** — what the system does in plain terms, who the
+ callers are, what breaks for them if it's down. Two or three paragraphs, no diagrams. A
+ reader should be able to stop here and know whether they care.
+2. **How it works** — a Mermaid diagram for every flow that earns one, not a representative
+ sample. Lead with the common case, then the paths people get wrong. Read path before write
+ path. See "Which flows get a diagram" below.
+3. **Data model** — what the tables/collections are, what the non-obvious columns mean, what
+ nullable actually signifies.
+4. **Interfaces** — endpoints, Celery tasks, management commands, webhooks. Who calls each one.
+5. **Sharp edges** — the non-obvious behaviour that will bite. See below.
+6. **Known gaps** — what's unresolved, undocumented, or actively suspicious, and what you'd
+ look at first.
+
+After each diagram, add a short "N things worth calling out" list for what the diagram can't
+say: why it's shaped this way, what it costs, what it rules out, and where the obvious-looking
+change is the wrong one.
+
+## Sharp edges
+
+This is the section that justifies the report, and the one only a current owner can write. It
+is not a list of bugs — it's the knowledge that would otherwise be transferred by someone
+losing a day. This repo's memory already holds candidates worth mining when writing about a
+given subsystem (e.g. the `Event` PK being `uuid` not `id` breaking `Count("id")`, or
+`INGEST_SHARD_COUNT` silently limiting a plain `ingest_events` run) — check for standing
+gotchas before assuming you've found something new.
+
+Good entries: a field whose name lies about its contents; a sentinel value overloaded into a
+normal column; ordering that looks incidental but isn't; a retry that's safe only because a
+downstream call happens to be idempotent; a config that behaves differently in one environment
+(dev vs. prod `DJANGO_ENV`, sharded vs. unsharded ingest); a "temporary" workaround load-bearing
+enough that removing it breaks something distant.
+
+Each entry: what it is, why it's that way, what happens if someone "fixes" it.
+
+## Table shapes
+
+Describe what exists. Columns adapt to the domain — layer, owner, and consumer columns all
+earn their place in different systems.
+
+| Table | Column | Meaning | Notes |
+|---|---|---|---|
+| `account` | `locked_until` | Null = not locked | Cleared on successful sign-in alongside the counter, in the sign-in handler (`auth.ts`). |
+
+| Endpoint | Auth | Called by | Description |
+|---|---|---|---|
+| `GET /v2/staff/thing` | `view_thing` | Admin settings screen | Returns the caller's groups only; 404s rather than 403s on a foreign id. |
+
+Name concrete values. "An expired sentinel" is not knowledge — `-infinity` is. If a column is
+nullable and carries a magic value, state exactly what distinguishes null from the sentinel;
+that ambiguity is a classic sharp edge.
+
+## If the report covers proposed changes instead
+
+Same structure, two swaps: add a New/Mod column to the tables so the reader can see what's
+changing versus what's already there, and replace Sharp edges with a delivery sequence table
+mapping each PR to the diagrams and rows it covers. Everything else — grounding, standalone
+rule, diagram discipline — applies unchanged.
+
+## The standalone rule
+
+- No "companion to `.md`", no "per §3 of the plan", no local file paths as pointers. The
+ reader will not open them. Restate what they need inline, in a clause. (Naming a module is
+ different — that's a signpost for verifying, not a document the reader has to go read.)
+- Number sections contiguously. Don't leave a §2→§4 gap advertising a missing piece.
+- Every § reference must resolve inside the report.
+- If the report is derived from a working doc (a design doc, a ticket thread), treat it as a
+ published copy. Regenerate when the source changes rather than patching, and expect the two
+ to diverge in structure.
+
+## Diagrams
+
+### Which flows get a diagram
+
+Diagram every flow that qualifies. Not one showcase diagram and prose for the rest — a
+newcomer hits the uncommon paths too, and the retry, the failure, and the migration are exactly
+the ones prose handles worst.
+
+A flow earns a diagram when any of these is true:
+
+- It crosses two or more components (view → DB → Celery task → worker).
+- It branches in a way that changes the outcome (authorized vs. not, cache hit vs. miss, first
+ run vs. subsequent).
+- Ordering matters and isn't obvious from the names.
+- State persists between steps, or a step is only safe because an earlier one ran.
+- You catch yourself writing "then… then… but if…". That sentence is a diagram you haven't
+ drawn yet.
+
+Skip it when the flow is one call and a return, when there's no branch and fewer than three
+steps, or when the diagram would restate the sentence above it. Ten diagrams that each earn
+their place is a good report; ten that include four trivial ones trains the reader to skip all
+of them.
+
+Diagrams aren't confined to "How it works." A status lifecycle (`StagedEvent` states, a
+`SourceRun` health level) belongs beside the data model; a decision tree explaining a sharp edge
+belongs next to that sharp edge. Put the diagram where the question gets asked.
+
+### Don't draw the same diagram twice
+
+A redundant diagram costs more than a missing one. It pads the report, and it makes the reader
+stop and work out whether two near-identical pictures differ in some way that matters.
+
+Before adding one, check it against what's already drawn:
+
+- **Same shape, different noun.** If "poll an ICS source" and "poll a scraper source" are the
+ same six steps with a different fetcher, draw it once and note what varies.
+- **Happy path plus one branch.** A failure that diverges for a step or two is an `alt` block
+ inside the existing diagram, not a second diagram.
+- **A subset.** If the read path is the write path with the last three steps removed, the write
+ diagram already showed it.
+- **A table redrawn.** An `erDiagram` that restates the data-model table adds nothing. Keep
+ whichever one the reader will actually use, not both.
+
+Split when the shape diverges; merge when only the values do. A branch that changes which
+participants are involved, or most of the steps, earns its own diagram. A branch that changes
+one call or a label is an `alt`.
+
+### Picking the type
+
+Reaching for `sequenceDiagram` every time is the most common way these reports get harder to
+read than they need to be.
+
+| What you're explaining | Type |
+|---|---|
+| Ordered interaction between components | `sequenceDiagram` |
+| One entity moving through statuses over its lifetime | `stateDiagram-v2` |
+| Branching logic, a decision tree, a dispatch table | `flowchart TD` |
+| How tables/collections relate | `erDiagram` |
+| What runs in parallel and what blocks | `flowchart LR` with subgraphs |
+
+If a "flow" is really "this record can be in one of six states and only some transitions are
+legal" (e.g. `StagedEvent.status`), a sequence diagram will fight you the whole way.
+
+### Keeping them readable
+
+- One flow per diagram — when the flows genuinely differ in shape. A diagram covering both a
+ substantial read and a substantial write is two diagrams; one covering a happy path and its
+ one-step failure is still one. See "Don't draw the same diagram twice."
+- Five participants is a lot. Middleware, context objects, and validators are not participants
+ — fold them into self-calls (`API->>API: check perms`).
+- Watch nesting. `alt` inside `alt` inside `loop` renders wide and overflows a tracker's
+ comment column. Two levels is usually the limit.
+- Autonumber so readers can say "step 7" instead of describing a line.
+
+### Mermaid syntax landmines
+
+Mermaid renders labels as HTML — which is why ` ` works, and why these two break with
+errors that don't point at the cause:
+
+- `;` is a statement separator. A semicolon in message or Note text truncates the statement; the
+ remainder parses as a new one and throws `Expecting 'SPACE', 'NEWLINE', 'create', 'box'...`.
+ Use a comma or dash.
+- `<...>` is swallowed as an HTML tag. `col = ` silently loses the ``. Use
+ `(sentinel)`.
+
+Both are silent — one throws an error pointing nowhere near the cause, the other doesn't error
+at all. Don't try to spot them by reading; run the two greps in "Before you publish."
+
+### Where it renders
+
+GitHub renders ```` ```mermaid ```` blocks natively — this matters here because handoff reports
+land in `human-docs/` and get read on GitHub. VS Code's built-in Markdown preview does not — it
+needs an extension, so don't promise it renders "everywhere."
+
+Don't build an HTML page or separate artifact just to make diagrams render; GitHub already
+renders them in `human-docs/`. Build a standalone artifact only when the content is genuinely
+interactive, and deliver it through its own channel rather than linking it from a report the
+reader can't follow the link from.
+
+You usually can't verify rendering locally — `mmdc` is rarely installed. Say so and preview on
+GitHub (or in the destination surface) before publishing.
+
+## Process
+
+1. **Establish who reads it and where it lands.** A newcomer needs the business context; an
+ integrating workstream needs the interfaces. It's landing in `human-docs/` per "Where the
+ finished report lands" above, not `docs/` — that decides emphasis, length, and structure.
+2. **Read the system-of-record docs first**, per `CLAUDE.md`: root `AGENTS.md` →
+ `ARCHITECTURE.md` → `CODING_STYLE.md`, plus whichever per-directory `AGENTS.md` or
+ `docs/*.md` already touches this subsystem. Note where they're stale — flag drift rather
+ than propagating it.
+3. **Read the code and note which modules matter as you go.** Models, urls/views, tasks. This
+ is most of the work, and skipping it is how reports become fiction.
+4. **List every flow before drawing any of them.** Enumerate the paths through the system —
+ happy path, each failure, each branch, each background job — mark which qualify per "Which
+ flows get a diagram," then collapse the ones that share a shape. Deciding coverage up front
+ is what stops the report from diagramming the first flow and prosing the rest, and the
+ collapse pass is what stops the list from becoming twelve variations on one picture.
+5. **Draft context → diagrams → tables → sharp edges → gaps.** Sharp edges accumulate while you
+ read; keep a running list from step 3.
+6. **Reread as the newcomer.** Every unexplained noun is a gap. Every "obviously" is a sharp
+ edge you forgot to write down.
+7. **Run the checks below**, then reread for the standalone rule — that one is invisible to
+ grep.
+8. **Add the row to `human-docs/README.md`** before calling it done — an unlisted doc doesn't
+ count as written, per "Where the finished report lands."
+9. **Have the current owner review before publishing.** They'll catch the confident-but-wrong
+ sentence, which is the one that does real damage. Don't commit or push it yourself unless
+ explicitly asked — surface it for review first.
+
+### Before you publish
+
+Two mechanical checks worth running literally, because both failures are silent and neither is
+reliably caught by reading. Set `F` to the file.
+
+```bash
+# 1. Semicolons inside a diagram — ';' ends the statement, and the rest of the
+# line parses as a new one. Error message points nowhere near the cause.
+awk '/^```mermaid/{b=1;next} /^```/{b=0} b{print NR": "$0}' "$F" | grep ';'
+
+# 2. Stray angle brackets — labels render as HTML, so is swallowed
+# with no error at all. Strips and arrows first; any hit is real.
+awk '/^```mermaid/{b=1;next} /^```/{b=0} b{print NR": "$0}' "$F" \
+ | sed -e 's| ||g' -e 's|[-=][-.=]*>>*||g' | grep '[<>]'
+```
+
+Both should print nothing. Then check by eye, in rough order of how much damage each does:
+
+1. **Does it stand alone?** Any pointer to another doc, any § that resolves nowhere, any
+ section-number gap. This is the failure that wastes the most reader time and no tool sees
+ it.
+2. **Any line numbers?** `grep -nE '\.[a-z]+:[0-9]+' "$F"` — name the module instead.
+3. **Do the diagrams cover the flows that matter, without duplicating each other?** Both
+ directions are failures.
+4. **Are the sharp edges the real ones?** If they read like a changelog rather than hard-won
+ knowledge, the section isn't done.
+5. **Is it in `human-docs/README.md`?**
+
+None of this tells you the report is correct — that's what owner review in step 9 is for, and
+it's the check that matters most.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ac40d4b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,95 @@
+# Root .dockerignore — applies to any build whose context is the repo root
+# (currently: Dockerfile.frontend, context `.`). backendServer/ has its own
+# .dockerignore for its own context (backendServer/); this file doesn't
+# replace that, it covers the root-context builds.
+#
+# Keep in sync with .gitignore where the two overlap. The point of this file
+# is as much about SECRETS as it is about build speed: the repo root holds a
+# real private key (oraclevps.key) and broadcastWeb/ holds a committed cert
+# (commons-broadcast.pem) — neither may ever enter a build context.
+#
+# IMPORTANT — verified empirically against this Docker Desktop/buildkit
+# version: a pattern WITHOUT a leading `**/` only matches at the exact
+# context-root path, even with no slash in the pattern at all (e.g. bare
+# `*.pem` did NOT exclude a nested broadcastWeb/commons-broadcast.pem in a
+# throwaway test build — only `**/*.pem` did). This is stricter than plain
+# .gitignore semantics, where a no-slash pattern matches at any depth. So
+# every pattern below that must also apply inside a subdirectory (which is
+# nearly all of them, since theCommonsWeb/, broadcastWeb/, backendServer/
+# are subdirectories of this context) carries an explicit `**/` form —
+# don't "simplify" these back down to bare patterns.
+
+# ── Secrets — must never enter a build context ────────────────────────────
+oraclevps.key
+*.key
+**/*.key
+*.pem
+**/*.pem
+.env
+**/.env
+.env.*
+**/.env.*
+!.env.example
+!**/.env.example
+
+# ── VCS ────────────────────────────────────────────────────────────────────
+.git/
+.gitignore
+
+# Claude Code worktrees — full duplicate checkouts (own node_modules/.venv)
+# used for parallel agent sessions, not application source. Never relevant
+# to a build and, left in, they alone can add several hundred MB of stale
+# installs to the context.
+.claude/worktrees/
+
+# ── Node ───────────────────────────────────────────────────────────────────
+# node_modules is reinstalled fresh inside each stage via `pnpm install
+# --frozen-lockfile` — a host-built node_modules (wrong OS/arch: dev is
+# Apple Silicon, prod is Oracle ARM64; also pnpm's node_modules is a symlink
+# farm into a content-addressed store that doesn't travel with the folder)
+# must never be copied in.
+node_modules/
+**/node_modules/
+
+# Build output — produced fresh inside the image, not copied from host.
+.next/
+**/.next/
+dist/
+**/dist/
+
+# TypeScript incremental build cache (generated, host-specific, diverges on
+# every build — see root .gitignore).
+*.tsbuildinfo
+**/*.tsbuildinfo
+
+# ── Python / backend cruft (irrelevant to the frontend builds, but a root-
+# context build still walks the whole tree unless excluded here) ──────────
+backendServer/.venv/
+backendServer/staticfiles/
+**/.venv/
+**/staticfiles/
+__pycache__/
+**/__pycache__/
+*.pyc
+**/*.pyc
+.mypy_cache/
+**/.mypy_cache/
+.ruff_cache/
+**/.ruff_cache/
+.pytest_cache/
+**/.pytest_cache/
+
+# ── Runtime/generated data ──────────────────────────────────────────────────
+dump.rdb
+broadcast-extension.zip
+
+# ── Editor/OS cruft ─────────────────────────────────────────────────────────
+.DS_Store
+**/.DS_Store
+.idea/
+**/.idea/
+.vscode/
+**/.vscode/
+
+# Local docker-compose bind-mount targets
+**/.local-dev/
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2b11105..c27891d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -208,80 +208,171 @@ jobs:
username: ${{ secrets.ORACLE_USER }}
key: ${{ secrets.ORACLE_SSH_KEY }}
fingerprint: ${{ steps.fp.outputs.fingerprint }}
- # Mirrors DEPLOY.md §Deploying Updates (validated dry-run in 16-12-vm-prep).
- # uv is at /snap/bin (not on the non-interactive PATH); pnpm is a global
- # npm bin already on PATH. set -e aborts on the first failure.
+ # Mirrors DEPLOY.md §Deploying Updates. Containers via
+ # docker-compose.yml (T6/T7 of the Dockerization suite) replace the
+ # old uv-sync + pnpm-build + systemctl-restart flow. Requires the VM
+ # to have Docker Engine + the `docker compose` v2 plugin, and the
+ # deploy user in the `docker` group — one-time setup, documented in
+ # a separate ticket. No more `sudo`: group membership is enough.
+ # `-f docker-compose.yml` is REQUIRED on every invocation below — a
+ # bare `docker compose` also auto-loads docker-compose.override.yml
+ # (local-dev-only: plain HTTP, no cert, repo-relative bind mounts),
+ # which would deploy the dev config to prod. set -e aborts on the
+ # first failure.
script: |
set -e
- # CI=true lets pnpm purge/rebuild node_modules non-interactively (no TTY
- # over SSH); without it pnpm aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
- export CI=true
cd /home/ubuntu/thecommons
- # pnpm build regenerates tsconfig.tsbuildinfo locally each run, which used
- # to be tracked in git and would then block the fast-forward pull below.
- # It's a generated TS incremental-build cache, now gitignored — discard any
- # local copy so the pull can proceed even before that lands on this VM.
+ # The real frontend build now happens inside `docker compose
+ # build` (Dockerfile.frontend), not via a host-side pnpm, but a
+ # stray tracked/leftover tsconfig.tsbuildinfo from a pre-Docker
+ # deploy could still block the fast-forward below — cheap
+ # insurance to discard it before pulling.
git checkout -- theCommonsWeb/tsconfig.tsbuildinfo broadcastWeb/tsconfig.tsbuildinfo 2>/dev/null || true
git pull origin main
- cd backendServer
- /snap/bin/uv sync
+ # Build every image on the VM — arm64-native (the VM is Oracle
+ # Ubuntu 24.04 ARM64) and needs no registry. nextjs and
+ # broadcast-spa-build bake NEXT_PUBLIC_*/VITE_* values in as
+ # build args with safe placeholder defaults (see
+ # docker-compose.yml's header) — a bare `build` here would
+ # silently ship those placeholders (verified failure mode: a
+ # broadcast bundle with no thecommons.town API origin baked in,
+ # misrouting every call at runtime). Source the real env files
+ # and re-export under the *_BUILD_* names docker-compose.yml's
+ # build args actually read. Scoped to this subshell so these
+ # values (DATABASE_URL, BETTER_AUTH_SECRET, ...) don't linger in
+ # the wider script env.
+ (
+ set -a
+ . theCommonsWeb/.env.local
+ . broadcastWeb/.env
+ set +a
+ export NEXTJS_BUILD_DATABASE_URL="$DATABASE_URL"
+ export NEXTJS_BUILD_BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET"
+ export NEXTJS_BUILD_BETTER_AUTH_URL="$BETTER_AUTH_URL"
+ export NEXTJS_BUILD_NEXT_PUBLIC_BETTER_AUTH_URL="$NEXT_PUBLIC_BETTER_AUTH_URL"
+ export NEXTJS_BUILD_NEXT_PUBLIC_API_BASE_URL="$NEXT_PUBLIC_API_BASE_URL"
+ export NEXTJS_BUILD_NEXT_PUBLIC_THE_COMMONS_API_KEY="$NEXT_PUBLIC_THE_COMMONS_API_KEY"
+ export BROADCAST_BUILD_VITE_BROADCAST_API_BASE_URL="$VITE_BROADCAST_API_BASE_URL"
+ export BROADCAST_BUILD_VITE_BETTER_AUTH_URL="$VITE_BETTER_AUTH_URL"
+ export BROADCAST_BUILD_VITE_BROADCAST_EXTENSION_ID="$VITE_BROADCAST_EXTENSION_ID"
+
+ # Guard: `. env_file` is shell sourcing, NOT a dotenv parser, so a
+ # BARE value containing a shell metacharacter is mis-parsed rather
+ # than rejected. The real case this caught: theCommonsWeb/.env.local
+ # had an unquoted DATABASE_URL whose Neon query string contains `&`
+ # (`...?sslmode=require&channel_binding=require`). The shell reads
+ # `VAR=x & y=z` as "assign x in a BACKGROUND subshell, then assign
+ # y", so DATABASE_URL never reached this shell at all. Without this
+ # guard the export below sets it to the empty string, compose's
+ # `${NEXTJS_BUILD_DATABASE_URL:-}` treats empty as
+ # unset, and the build "succeeds" with a placeholder DB URL baked
+ # into the image — green pipeline, wrong artifact.
+ #
+ # Fix the env file (quote the value); this only makes the failure
+ # loud. Every var here is required, so empty is always a bug.
+ missing=""
+ for v in NEXTJS_BUILD_DATABASE_URL NEXTJS_BUILD_BETTER_AUTH_SECRET \
+ NEXTJS_BUILD_BETTER_AUTH_URL NEXTJS_BUILD_NEXT_PUBLIC_BETTER_AUTH_URL \
+ NEXTJS_BUILD_NEXT_PUBLIC_API_BASE_URL NEXTJS_BUILD_NEXT_PUBLIC_THE_COMMONS_API_KEY \
+ BROADCAST_BUILD_VITE_BROADCAST_API_BASE_URL BROADCAST_BUILD_VITE_BETTER_AUTH_URL; do
+ eval "val=\${$v:-}"
+ [ -n "$val" ] || missing="$missing $v"
+ done
+ if [ -n "$missing" ]; then
+ echo "::error::empty build arg(s):$missing — a value in theCommonsWeb/.env.local or broadcastWeb/.env failed to survive shell sourcing (usually a bare value containing & or a space; quote it)"
+ exit 1
+ fi
+
+ docker compose -f docker-compose.yml build
+ )
+
+ # Bug-class check, now run against the built image instead of a
+ # host dist/ dir: a malformed VITE_BROADCAST_API_BASE_URL (e.g.
+ # missing "//") builds "successfully" but silently misroutes
+ # every API call. Fail here, before the broken image goes live,
+ # rather than discovering it later via a confusing 404/405 in
+ # production.
+ if ! docker compose -f docker-compose.yml run --rm -T --no-deps --entrypoint sh broadcast-spa-build \
+ -c 'grep -rq "https\?://[a-zA-Z0-9.-]*thecommons\.town" /app/dist/assets/*.js'; then
+ echo "::error::broadcastWeb build does not reference a thecommons.town API origin — check broadcastWeb/.env"
+ exit 1
+ fi
# Guarded migrate: `migrate --check` exits non-zero when unapplied
# migrations exist and applies nothing (Django 6: "Exits with a
# non-zero status if unapplied migrations exist and does not
# actually apply migrations"). So prod only writes schema when
- # there is real work — and never without a fresh pre-migrate dump.
- if /snap/bin/uv run python manage.py migrate --check; then
+ # there is real work — and never without a fresh pre-migrate
+ # dump. Django commands run via one-shot containers off the
+ # image just built above; --no-deps skips starting redis/etc.
+ # for a plain management command.
+ if docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py migrate --check; then
echo "No unapplied migrations — skipping migrate."
else
echo "Unapplied migrations detected — plan tail:"
- /snap/bin/uv run python manage.py showmigrations --plan | tail -20
- # A silent skip here would defeat the guard: no pg_dump = no
- # backup = hard failure, never a warning.
- if ! command -v pg_dump >/dev/null 2>&1; then
- echo "::error::pg_dump not found on the VM — run: sudo apt install postgresql-client. Refusing to migrate without a pre-migrate backup."
- exit 1
- fi
+ docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py showmigrations --plan | tail -20
+
mkdir -p /home/ubuntu/backups
- # Subshell so backendServer/.env (DATABASE_URL etc.) never leaks
- # into the rest of this script's env or the log (set -x stays
- # off). The app's DATABASE_URL already carries Neon's sslmode,
- # so pg_dump consumes it as-is. pipefail: a failed pg_dump must
- # not leave a truncated-but-"green" gzip behind. This dump is
+ # pg_dump now runs in a postgres:18-alpine container: the VM no
+ # longer ships postgresql-client, and the backend image (slim
+ # Python) never did. Pinned to 18 deliberately — pg_dump can
+ # dump servers older than itself but refuses newer ones, so the
+ # newest client stays unconditionally safe against whatever
+ # version Neon runs. Subshell so backendServer/.env
+ # (DATABASE_URL etc.) never leaks into the rest of this
+ # script's env or the log (set -x stays off); `-e
+ # DATABASE_URL` (no `=value`) passes it into the container by
+ # reference so the value never appears in argv either.
+ # `/home/ubuntu/backups` is bind-mounted so the dump lands on
+ # the host and survives the container being removed (--rm).
+ # set -o pipefail (verified supported by postgres:18-alpine's
+ # /bin/sh): a failed pg_dump must not leave a
+ # truncated-but-"green" gzip behind. A silent skip here would
+ # defeat the guard: no dump = no backup = hard failure, never a
+ # warning — set -e (outer) + pipefail (inner) already enforce
+ # that without a separate tool-existence check. This dump is
# belt-and-suspenders — Neon PITR/branching is the real restore
# mechanism (see DEPLOY.md).
(
- set -o pipefail
- set -a; . ./.env; set +a
- pg_dump "$DATABASE_URL" | gzip > "/home/ubuntu/backups/pre-migrate-$(date +%Y%m%d-%H%M%S).sql.gz"
+ set -a; . backendServer/.env; set +a
+ docker run --rm \
+ -e DATABASE_URL \
+ -v /home/ubuntu/backups:/backups \
+ postgres:18-alpine \
+ sh -c 'set -o pipefail; pg_dump "$DATABASE_URL" | gzip > "/backups/pre-migrate-$(date +%Y%m%d-%H%M%S).sql.gz"'
)
# Keep only the 5 newest dumps.
ls -1t /home/ubuntu/backups/pre-migrate-*.sql.gz | tail -n +6 | xargs -r rm -f --
- /snap/bin/uv run python manage.py migrate --noinput
+ docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py migrate --noinput
fi
- /snap/bin/uv run python manage.py collectstatic --noinput
-
- cd ../theCommonsWeb
- pnpm install --frozen-lockfile
- pnpm run build
-
- cd ../broadcastWeb
- pnpm install --frozen-lockfile
- pnpm run build
+ # collectstatic is no longer a deploy step — it now runs at image
+ # build time (backendServer/Dockerfile bakes it into
+ # /app/staticfiles_build/static, and deploy/nginx/Dockerfile
+ # COPY --from='s it into the nginx image above).
+ #
+ # pnpm install/build are no longer deploy steps either —
+ # Dockerfile.frontend does both frontend builds as part of the
+ # `docker compose build` above; there is no host-side
+ # node_modules or build step left to run.
- # Bug-class check: a malformed VITE_BROADCAST_API_BASE_URL (e.g. missing
- # "//") builds "successfully" but silently misroutes every API call.
- # Fail here, before the broken build goes live, rather than discovering
- # it later via a confusing 404/405 in production.
- if ! grep -rq 'https\?://[a-zA-Z0-9.-]*thecommons\.town' dist/assets/*.js; then
- echo "::error::broadcastWeb build does not reference a thecommons.town API origin — check broadcastWeb/.env"
- exit 1
- fi
+ # Recreate every service from the images just built. No more
+ # `sudo systemctl restart` — the deploy user is in the `docker`
+ # group, so this runs unprivileged.
+ docker compose -f docker-compose.yml up -d
- sudo -n systemctl restart gunicorn nextjs celery celerybeat broadcast-worker scrape-worker
- systemctl is-active gunicorn nextjs celery celerybeat broadcast-worker scrape-worker
+ # Health assertion replacing `systemctl is-active`. migrate and
+ # broadcast-spa-build are one-shot (`restart: "no"`) and exit 0
+ # by design, so they're excluded from the "still running" check.
+ docker compose -f docker-compose.yml ps
+ running_services=$(docker compose -f docker-compose.yml ps --status running --services)
+ for svc in redis backend celery celerybeat broadcast-worker scrape-worker nextjs nginx; do
+ if ! printf '%s\n' "$running_services" | grep -qx "$svc"; then
+ echo "::error::service '$svc' is not running after deploy — see docker compose ps output above"
+ exit 1
+ fi
+ done
- name: Post-deploy smoke test
uses: appleboy/ssh-action@v1
with:
@@ -312,6 +403,16 @@ jobs:
check "https://thecommons.town/" 200
check "https://broadcast.thecommons.town/" 200
check "https://api.thecommons.town/events/" 200
+ # Fourth origin, easy to forget because nothing user-facing points
+ # at it directly: auth.thecommons.town is what makes the
+ # .thecommons.town cookie domain work across subdomains (suite 37),
+ # and backendServer/.env's BETTER_AUTH_JWKS_URL points here, so every
+ # broadcast JWT verification fetches this exact URL. Probing the JWKS
+ # endpoint rather than `/` checks the thing the backend depends on.
+ # Dropping this server block from deploy/nginx/thecommons.conf makes
+ # the hostname fall through to the www->apex redirect, which the
+ # garbage-JWT probe below would only report indirectly as a 500.
+ check "https://auth.thecommons.town/api/auth/jwks" 200
# Regression check for the Unix-socket REMOTE_ADDR gap: nginx proxies
# to gunicorn over a Unix socket, which used to leave REMOTE_ADDR
diff --git a/.gitignore b/.gitignore
index 51fb006..1854c45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,6 @@ dump.rdb
# Chrome Web Store package (generated by extensionzipper.sh)
broadcast-extension.zip
+
+# Local docker-compose bind-mount targets (docker-compose.override.yml)
+.local-dev/
diff --git a/AGENTS.md b/AGENTS.md
index 06036de..abe5bbe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,8 +9,11 @@ thecommons/
├── backendServer/ # Django 6 + DRF — API, ingestion pipeline, broadcast, async
│ ├── backend/ # Project config: settings/ (base/dev/prod/test), urls, celery,
│ │ # jwt_auth (Better Auth JWKS), permissions, test_runner
-│ ├── events/ # Public app: Event/Town/Tag/Category/UserProfile/Business +
-│ │ # neon_auth mirrors, digests (Brevo), Redis cache, Celery tasks
+│ ├── accounts/ # Identity/auth-bridge app: neon_auth mirrors (BetterAuth*),
+│ │ # UserProfile + BusinessProfile, /auth/me, /businesses
+│ ├── events/ # Public app: Event/Town/Tag/Category + genuine event views
+│ ├── newsletter/ # NewsletterSubscriber, subscribe/manage views, digest engine
+│ │ # (email_service, tasks, templates, digest commands)
│ ├── ingestion/ # Pipeline: EventSource → RawEvent → StagedEvent → published Event
│ ├── broadcast/ # Event syndication: Playwright adapters, DB-queue worker, routing
│ ├── templates/ # HTML for admin docs pages + email digests
@@ -71,17 +74,17 @@ Run backend + theCommonsWeb together for end-to-end auth (Django validates JWTs
| Concern | Key files | Deep dive |
|---------|-----------|-----------|
-| Auth bridge | `backend/jwt_auth.py`, `backend/permissions.py`, `src/lib/auth.ts` | [ARCHITECTURE.md §Authentication](ARCHITECTURE.md#authentication) |
-| Data models | `events/models.py`, `ingestion/models.py`, `broadcast/models.py` | [ARCHITECTURE.md §Data Models](ARCHITECTURE.md#data-models) |
-| API endpoints | `backend/urls.py`, `events/urls.py`, `broadcast/urls.py` | [ARCHITECTURE.md §API Endpoints](ARCHITECTURE.md#api-endpoints) |
+| Auth bridge | `backend/jwt_auth.py`, `backend/permissions.py`, `accounts/models.py`, `src/lib/auth.ts` | [ARCHITECTURE.md §Authentication](ARCHITECTURE.md#authentication) |
+| Data models | `accounts/models.py`, `events/models.py`, `newsletter/models.py`, `ingestion/models.py`, `broadcast/models.py` | [ARCHITECTURE.md §Data Models](ARCHITECTURE.md#data-models) |
+| API endpoints | `backend/urls.py` (include()-only), `accounts/urls.py`, `events/urls.py`, `newsletter/urls.py`, `ingestion/urls.py`, `broadcast/urls.py` | [ARCHITECTURE.md §API Endpoints](ARCHITECTURE.md#api-endpoints) |
| Ingestion pipeline | `ingestion/services.py`, `ingestion/standardizer.py`, `ingestion/importers/`, `ingestion/safety_scorer.py` | [docs/ingestion-pipeline.md](docs/ingestion-pipeline.md) |
| Safety scoring | `ingestion/safety_scorer.py` | [docs/safety-scoring.md](docs/safety-scoring.md) |
| Broadcast | `broadcast/services.py`, `broadcast/worker.py`, `broadcast/runner.py`, `broadcast/adapters/` | [docs/broadcast.md](docs/broadcast.md) |
-| Redis + Celery | `backend/celery.py`, `events/tasks.py`, `ingestion/tasks.py`, `events/cache.py` | [docs/redis-celery-handoff.md](docs/redis-celery-handoff.md) |
+| Redis + Celery | `backend/celery.py`, `newsletter/tasks.py`, `ingestion/tasks.py`, `events/tasks.py`, `events/cache.py` | [docs/redis-celery-handoff.md](docs/redis-celery-handoff.md) |
| Frontend data layer | `src/lib/queryClient.ts`, `src/hooks/useEvents.ts`, `src/services/` | [ARCHITECTURE.md §Frontend](ARCHITECTURE.md#frontend-architecture) |
-| Email digests | `events/email_service.py`, `events/tasks.py` | [ARCHITECTURE.md §Async](ARCHITECTURE.md#async-redis--celery) |
+| Email digests | `newsletter/email_service.py`, `newsletter/tasks.py` (generic transport: `events/email_service.py::send_email`) | [ARCHITECTURE.md §Async](ARCHITECTURE.md#async-redis--celery) |
| Design system | `src/app/globals.css`, `src/components/ui/` | [CODING_STYLE.md](CODING_STYLE.md) |
-| Admin UI | `events/admin.py`, `ingestion/admin.py` | [docs/admin-backend.md](docs/admin-backend.md) |
+| Admin UI | `accounts/admin.py`, `events/admin.py`, `newsletter/admin.py`, `ingestion/admin.py` | [docs/admin-backend.md](docs/admin-backend.md) |
| Testing & CI | `backend/settings/test.py`, `.github/workflows/ci.yml`, `vitest.config.ts` | [ARCHITECTURE.md §Testing](ARCHITECTURE.md#testing--ci) |
| Dev DB isolation | Neon dev branch + `settings/dev.py` | [docs/dev-db-isolation.md](docs/dev-db-isolation.md) |
| Deployment | systemd units, nginx, env vars on VM | [DEPLOY.md](DEPLOY.md) |
@@ -91,7 +94,7 @@ Run backend + theCommonsWeb together for end-to-end auth (Django validates JWTs
- **Never migrate `neon_auth` tables.** Better Auth (Next.js) owns them. Django mirrors are `managed = False`.
- **`Town` and `Category` are SQL tables** — don't hardcode. Pipeline skips events with unknown town slugs.
- **Auth lives in Next.js**, not Django. Don't add Django login/signup views or use `django.contrib.auth.User` for app users.
-- **`broadcast/` is isolated from `events/`.** `broadcast/routing.py` must not import from `events` (enforced by tests).
+- **`broadcast/` and `ingestion/` are isolated from the rest.** No app imports from `ingestion`/`broadcast`; `broadcast/routing.py` must not import from `events` (enforced by tests). `accounts`, `newsletter`, and `events` may read each other where the domain genuinely overlaps — e.g. `accounts.me` writes a `NewsletterSubscriber` row (email-preference sync) and `newsletter._build_recipients` reads `accounts.UserProfile` (tag-filtered digests). Both directions are intentional; don't "fix" the coupling.
- **No Django ORM inside `sync_playwright`** — fetch all data into plain objects first, then drive the browser.
- **Redis layout is fixed:** DB 0 = Celery broker + results, DB 1 = Django cache. Don't mix them.
- **pnpm only for frontends** (pinned to pnpm 11). `npm install` breaks the symlinked store / peer pinning.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b3558da..2dfb3ee 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -19,35 +19,67 @@ Data lives in Postgres on Neon (`public` schema owned by Django, `neon_auth` sch
## Data Models
-**Key files:** `events/models.py`, `ingestion/models.py`, `broadcast/models.py`
+**Key files:** `accounts/models.py`, `events/models.py`, `newsletter/models.py`, `ingestion/models.py`, `broadcast/models.py`
-### `events` app — `public` schema (managed)
+### `accounts` app — identity / auth-bridge
+
+`accounts` owns the Better Auth mirrors and every profile that hangs off a user — a
+"business" is modeled as a kind of user profile, not a separate app.
| Model | Key fields | Relationships |
|-------|-----------|---------------|
-| `Tag` | `name` (unique) | M2M from users/businesses/events |
-| `Town` | `slug` (unique), `name` | FK target of `Event.town` |
-| `Category` | `slug` (unique), `display_name` | M2M with `Event` |
-| `UserProfile` | `uuid`, `user_type` (LOCAL/BUSINESS/VENUE), `primary_city`, `address`, `email_preference` (WEEKLY/MONTHLY/NEVER) | OneToOne→`BetterAuthUser` (`db_constraint=False`); M2M→`Tag` |
-| `BusinessProfile` | `uuid`, `business_name`, `description`, `contact_email/phone`, `is_published`, timestamps | OneToOne→`BetterAuthUser`; M2M→`Tag`; M2M→`Town` (`service_area`) |
-| `NewsletterSubscriber` | `email` (unique), `frequency`, `is_active`, `manage_token` (UUID, unique — unguessable credential for the manage link), `subscribed_at` | — |
-| `Event` | `uuid` (PK), `title`, `date` (indexed), `venue`, `description`, `price`, `photo`, `link`, `is_verified`, `source_name` | FK→`Town` (SET_NULL); M2M→`Tag`, `Category`; FK→`BetterAuthUser` (`created_by`) |
+| `UserProfile` | `uuid`, `user_type` (LOCAL/BUSINESS/VENUE), `primary_city`, `address`, `email_preference` (WEEKLY/MONTHLY/NEVER) | OneToOne→`BetterAuthUser` (`db_constraint=False`); M2M→`events.Tag`. `db_table="events_userprofile"` (unchanged — model moved app, table didn't) |
+| `BusinessProfile` | `uuid`, `business_name`, `description`, `contact_email/phone`, `is_published`, timestamps | OneToOne→`BetterAuthUser`; M2M→`events.Tag`; M2M→`events.Town` (`service_area`). `db_table="events_businessprofile"` |
-### Better Auth mirrors — `neon_auth` schema (`managed = False`)
+#### Better Auth mirrors — `neon_auth` schema (`managed = False`)
-Better Auth (Next.js) owns these tables; Django maps them **read-only** for joins. **Never create migrations for them.** Models: `BetterAuthUser`, `BetterAuthSession`, `BetterAuthAccount`, `BetterAuthVerification`, `BetterAuthJwks`.
+Better Auth (Next.js) owns these tables; Django maps them **read-only** for joins. **Never create migrations for them.** Models: `BetterAuthUser`, `BetterAuthSession`, `BetterAuthAccount`, `BetterAuthVerification`, `BetterAuthJwks` — all in `accounts.models`.
- The `db_table` values use a double-quote trick (e.g. `'neon_auth"."user'`) so Django emits a valid cross-schema reference `FROM "neon_auth"."user"`.
- `BetterAuthUser` hardcodes `is_authenticated=True` / `is_anonymous=False` so DRF permission classes treat it as a real user.
- FKs into these mirrors use `db_constraint=False` (no DB-level FK against unmanaged tables).
+### `events` app — `public` schema (managed, slimmed)
+
+`events` now owns only the genuine event/taxonomy models; profile and newsletter models
+moved to `accounts`/`newsletter` respectively (state-only migrations — see below).
+
+| Model | Key fields | Relationships |
+|-------|-----------|---------------|
+| `Tag` | `name` (unique) | M2M from users/businesses/events |
+| `Town` | `slug` (unique), `name` | FK target of `Event.town` |
+| `Category` | `slug` (unique), `display_name` | M2M with `Event` |
+| `Event` | `uuid` (PK), `title`, `date` (indexed), `venue`, `description`, `price`, `photo`, `link`, `is_verified`, `source_name` | FK→`Town` (SET_NULL); M2M→`Tag`, `Category`; FK→`accounts.BetterAuthUser` (`created_by`) |
+
+### `newsletter` app — `public` schema (managed)
+
+| Model | Key fields | Relationships |
+|-------|-----------|---------------|
+| `NewsletterSubscriber` | `email` (unique), `frequency`, `is_active`, `manage_token` (UUID, unique — unguessable credential for the manage link), `subscribed_at` | — . `db_table="events_newslettersubscriber"` (unchanged) |
+
+There is a deliberate `accounts ↔ newsletter` coupling, not a boundary bug: `accounts.me`
+writes a `NewsletterSubscriber` row (email-preference sync) and
+`newsletter._build_recipients` reads `accounts.UserProfile` (tag-filtered digests). Both
+directions are intentional and covered by each app's `test_isolation_fast.py` (which forbid
+reaching into `ingestion`/`broadcast`, not into each other or `events`).
+
+### Migration mechanics for the model moves
+
+All three moves (`UserProfile`/`BusinessProfile` → `accounts`, `NewsletterSubscriber` →
+`newsletter`) used `migrations.SeparateDatabaseAndState` with `db_table` preserved
+(`events_userprofile`, `events_businessprofile`, `events_newslettersubscriber`) — state-only,
+zero physical DDL. The `neon_auth.*` mirrors were never migrated (still `managed=False`).
+A companion data migration (`newsletter/migrations/0002_repoint_digest_beat.py`) repoints the
+existing `django_celery_beat` `PeriodicTask` rows from `events.tasks.fan_out_*_digest` to
+`newsletter.tasks.fan_out_*_digest`.
+
### `ingestion` app — `public` schema (managed)
| Model | Key fields | Relationships |
|-------|-----------|---------------|
| `EventSource` | `name`, `source_type` (ics/scraper/email/**direct**), `url`, `active`, `last_polled`, `poll_interval_hours` | reverse `raw_events` |
| `RawEvent` | raw title/description/location, raw start/end, `source_url`, `source_uid`, `processed` | FK→`EventSource`; `unique_together=(source, source_uid)` |
-| `StagedEvent` | LLM fields (title, description, location, town, datetimes, tags JSON, category, price, link), `status` (pending/approved/rejected/duplicate), `safety_score/notes`, `reviewer_notes` | OneToOne→`RawEvent`; self-FK `duplicate_of`; FK→`events.Event` (`published_event`); FK→`BetterAuthUser` (`submitted_by`) |
+| `StagedEvent` | LLM fields (title, description, location, town, datetimes, tags JSON, category, price, link), `status` (pending/approved/rejected/duplicate), `safety_score/notes`, `reviewer_notes` | OneToOne→`RawEvent`; self-FK `duplicate_of`; FK→`events.Event` (`published_event`); FK→`accounts.BetterAuthUser` (`submitted_by`) |
### `broadcast` app — `public` schema (managed)
@@ -70,27 +102,43 @@ Better Auth (Next.js) owns these tables; Django maps them **read-only** for join
## API Endpoints
-**Key files:** `backend/urls.py`, `events/urls.py`, `broadcast/urls.py`
+**Key files:** `backend/urls.py`, `accounts/urls.py`, `events/urls.py`, `newsletter/urls.py`, `ingestion/urls.py`, `broadcast/urls.py`
Notes that apply throughout:
- **`APPEND_SLASH=False`** — trailing slashes are matched exactly as written below.
- **No global DRF config.** Each view sets its own `@authentication_classes` / `@permission_classes` (house pattern).
- Auth column: `—` = public, `user` = Better Auth JWT, `API key` = `THE_COMMONS_API_KEY`, `tier≥N` = broadcast tier (Bearer JWT or `X-Broadcast-Access-Code`, resolved by `broadcast/access.py`).
+- **`backend/urls.py` is `include()`-only** — it delegates to each app's own urlconf (`accounts.urls`, `events.urls`, `newsletter.urls`, `ingestion.urls`, `broadcast.urls`, plus `admin.site.urls` and, when `DEBUG`, `devtools.urls`). There is no `from .views import ...` in the kernel; the tables below are grouped by the app that actually owns the route.
-### Root (`backend/urls.py`)
+### accounts (`accounts/urls.py`)
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
-| GET | `/api/cron/ingest` | `CRON_SECRET` | Queue the ingestion pipeline (Celery) |
-| POST | `/api/events/publish-approved` | API key | Queue bulk publish of approved staged events |
-| POST | `/api/events/direct-submit` | JWT optional (anonymous allowed) | Direct host event submission — fire-and-forget from broadcast SPA; 10/m by IP; invalid token → 401 |
| GET/PATCH | `/auth/me` | user | Read / update own profile |
-| POST | `/newsletter/subscribe` | — | Newsletter signup (`{email, frequency}`); sends a welcome email with a manage link |
-| GET/PATCH | `/newsletter/manage` | — (token) | Manage a subscription via `?token=` — GET returns `{email, frequency, is_active}`; PATCH body `{frequency: WEEKLY\|MONTHLY\|NEVER}` (`NEVER` sets `is_active=false`) |
| GET/POST | `/businesses` | user | Browse published businesses / create a listing |
| GET | `/businesses/me` | user | Own business listing |
| GET/PATCH/DELETE | `/businesses/` | user | Business listing CRUD |
+
+### newsletter (`newsletter/urls.py`)
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| POST | `/newsletter/subscribe` | — | Newsletter signup (`{email, frequency}`); sends a welcome email with a manage link |
+| GET/PATCH | `/newsletter/manage` | — (token) | Manage a subscription via `?token=` — GET returns `{email, frequency, is_active}`; PATCH body `{frequency: WEEKLY\|MONTHLY\|NEVER}` (`NEVER` sets `is_active=false`) |
+
+### ingestion (`ingestion/urls.py`)
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| GET | `/api/cron/ingest` | `CRON_SECRET` | Queue the ingestion pipeline (Celery) |
+| POST | `/api/events/publish-approved` | API key | Queue bulk publish of approved staged events |
+| POST | `/api/events/direct-submit` | JWT optional (anonymous allowed) | Direct host event submission — fire-and-forget from broadcast SPA; 10/m by IP; invalid token → 401 |
| GET/POST | `/admin/docs/...` | staff | Pipeline/admin docs pages + publish-approved button |
+
+### admin
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
| — | `/admin/` | staff | Django admin (django-unfold) |
### Events (`/events/`)
@@ -131,7 +179,7 @@ Auth via Bearer JWT or `X-Broadcast-Access-Code` header, resolved to a tier by `
## Authentication
-**Key files:** `backend/jwt_auth.py`, `backend/permissions.py`, `src/lib/auth.ts`, `src/lib/redirect-allowlist.ts`, `src/hooks/useAuth.tsx`, `src/app/(portal)/`, `src/components/layout/SiteChrome.tsx`
+**Key files:** `backend/jwt_auth.py`, `backend/permissions.py`, `accounts/models.py`, `src/lib/auth.ts`, `src/lib/redirect-allowlist.ts`, `src/hooks/useAuth.tsx`, `src/app/(portal)/`, `src/components/layout/SiteChrome.tsx`
Auth is owned by **Better Auth running inside Next.js**, fronted by a standalone **portal** — there are no Django login/signup endpoints, and no app renders its own embedded auth form anymore. Django only *verifies* tokens.
@@ -149,12 +197,12 @@ Every service that needs a user to authenticate redirects into the portal with `
- Browser authenticates with Better Auth and holds a session cookie.
- To call Django, the frontend fetches a short-lived **JWT** from `/api/auth/token` (Better Auth `jwt()` plugin) and sends it as `Authorization: Bearer `. JWT payload carries the `email` claim by default (`sub` = user id).
- `BearerTokenAuthentication` accepts either:
- 1. a **Better Auth JWT** verified statelessly against the JWKS endpoint (`BETTER_AUTH_JWKS_URL`); `sub` resolves to a `BetterAuthUser`. The JWKS client is cached in-process with a TTL and **stale-grace** fallback so brief Next.js outages don't cascade. In `broadcast/`, `verify_better_auth_jwt` is called directly — no `BetterAuthUser` ORM lookup (isolation preserved).
+ 1. a **Better Auth JWT** verified statelessly against the JWKS endpoint (`BETTER_AUTH_JWKS_URL`); `sub` resolves to a `BetterAuthUser` (now in `accounts.models` — `backend/permissions.py` imports it from there, not `events.models`). The JWKS client is cached in-process with a TTL and **stale-grace** fallback so brief Next.js outages don't cascade. In `broadcast/`, `verify_better_auth_jwt` is called directly — no `BetterAuthUser` ORM lookup (isolation preserved).
2. the shared **`THE_COMMONS_API_KEY`** (no user attached) — for app-level calls like event creation.
- Permission classes live in `backend/permissions.py` and are applied per-view alongside DRF's `IsAuthenticated`.
### User-creation side effect
-`src/lib/auth.ts` defines `databaseHooks.user.create.after`, which inserts a matching `public.events_userprofile` row whenever Better Auth creates a user — so every account has a Django profile.
+`src/lib/auth.ts` defines `databaseHooks.user.create.after`, which inserts a matching `public.events_userprofile` row whenever Better Auth creates a user — so every account has a Django profile. The table name is a historical artifact: the `UserProfile` model now lives in `accounts/models.py`, but its `db_table` was pinned to `events_userprofile` by a state-only migration, so the physical table (and this INSERT) is unchanged.
### Account creation is password-required
Signup collects **email + password + confirm** in one step on `/join`, via Better Auth's standard `emailAndPassword` flow (`autoSignIn: true` in `src/lib/auth.ts`) — `signUp.email` creates the Better Auth user + `credential` account and signs the user in immediately; the `databaseHook` fires as usual. There is no passwordless/email-only path and no separate set-password step. **No email verification for MVP.**
@@ -198,21 +246,21 @@ Flow: tier-based auth (Bearer JWT or access code, resolved by `broadcast/access.
## Async: Redis + Celery
-**Key files:** `backend/celery.py`, `backend/__init__.py`, `events/tasks.py`, `ingestion/tasks.py`, `events/cache.py`, `events/signals.py`
+**Key files:** `backend/celery.py`, `backend/__init__.py`, `newsletter/tasks.py`, `events/tasks.py`, `ingestion/tasks.py`, `events/cache.py`, `events/signals.py`
- **One Redis instance, two logical DBs:** DB 0 = Celery broker **and** result backend (`REDIS_URL`); DB 1 = Django cache (`RedisCache`, `REDIS_CACHE_URL`).
- **Celery** app is built in `backend/celery.py`, loaded eagerly via `backend/__init__.py`, and autodiscovers tasks. `CELERY_TIMEZONE = UTC` (beat entries carry their own tz).
-- **Beat** uses `django_celery_beat`'s `DatabaseScheduler` — schedules live in Postgres and are editable in admin. Seeded by migrations:
- - `weekly-digest-sunday` → `events.tasks.fan_out_weekly_digest`, Sun 18:00 America/New_York (`events/migrations/0015_seed_digest_beat.py`).
- - `monthly-digest` → `events.tasks.fan_out_monthly_digest`, 1st of month 18:00 America/New_York (`events/migrations/0020_seed_monthly_digest_beat.py`).
+- **Beat** uses `django_celery_beat`'s `DatabaseScheduler` — schedules live in Postgres and are editable in admin. Seeded by migrations, then repointed by a data migration when the digest engine moved apps:
+ - `weekly-digest-sunday` → `newsletter.tasks.fan_out_weekly_digest`, Sun 18:00 America/New_York (seeded by `events/migrations/0015_seed_digest_beat.py`; repointed from `events.tasks.fan_out_weekly_digest` by `newsletter/migrations/0002_repoint_digest_beat.py`).
+ - `monthly-digest` → `newsletter.tasks.fan_out_monthly_digest`, 1st of month 18:00 America/New_York (seeded by `events/migrations/0020_seed_monthly_digest_beat.py`; repointed by the same `0002_repoint_digest_beat.py`).
- `ingest-events-daily` → `ingestion.tasks.run_ingestion_pipeline`, 04:00 America/New_York (`ingestion/migrations/0007_seed_ingest_beat.py`).
-- **Tasks:** `events.tasks` (`ping`, `send_one_digest`, `fan_out_weekly_digest`, `fan_out_monthly_digest`), `ingestion.tasks` (`run_ingestion_pipeline`, `publish_all_approved_task`).
+- **Tasks:** `newsletter.tasks` (`send_one_digest`, `fan_out_weekly_digest`, `fan_out_monthly_digest`), `events.tasks` (`ping`), `ingestion.tasks` (`run_ingestion_pipeline`, `publish_all_approved_task`).
- **Read-endpoint cache:** `events/cache.py` is a version-keyed Redis cache for the hot list endpoints; `events/signals.py` bumps the version on `Event`/`Town`/`Category` writes to invalidate.
See [docs/redis-celery-handoff.md](docs/redis-celery-handoff.md).
### Email digests
-`events/email_service.py` wraps **Brevo** transactional email and builds digest HTML from `templates/email/`. `NewsletterSubscriber` is the single source of truth for both weekly and monthly digests: `_build_recipients(frequency)` resolves the recipient list (deduped by email) from active subscriber rows — anonymous newsletter subscribers get all events, account holders (`UserProfile.email_preference`) are tag-filtered — and returns `{email, tags, manage_token}` per recipient. This one resolver backs both the Celery path (`fan_out_weekly_digest` / `fan_out_monthly_digest` queue one `send_one_digest` per recipient) and the synchronous `send_digest`/`send_weekly_digest` management commands (`send_test_digest` sends a one-off test). Every digest email carries a "Manage preferences / Unsubscribe" link built from the recipient's `manage_token` (`/newsletter/manage?token=`).
+`newsletter/email_service.py` wraps **Brevo** (via the generic transport, `events/email_service.py::send_email`) and builds digest HTML from `newsletter/templates/email/`. `NewsletterSubscriber` (`newsletter` app) is the single source of truth for both weekly and monthly digests: `_build_recipients(frequency)` resolves the recipient list (deduped by email) from active subscriber rows — anonymous newsletter subscribers get all events, account holders (`accounts.UserProfile.email_preference`) are tag-filtered — and returns `{email, tags, manage_token}` per recipient. This one resolver backs both the Celery path (`newsletter.tasks.fan_out_weekly_digest` / `fan_out_monthly_digest` queue one `send_one_digest` per recipient) and the synchronous `send_digest`/`send_weekly_digest` management commands in `newsletter/management/commands/` (`send_test_digest` sends a one-off test). Every digest email carries a "Manage preferences / Unsubscribe" link built from the recipient's `manage_token` (`/newsletter/manage?token=`). `events/email_service.py::send_email` remains the generic Brevo wrapper used by non-digest commands — it did not move.
---
@@ -262,7 +310,7 @@ A separate Vite + React 19 SPA (`broadcastWeb/`) for the broadcast operator cons
**Key files:** `backend/settings/{base,dev,prod,test}.py`
Settings are split by `DJANGO_SETTINGS_MODULE`:
-- `base.py` — shared: installed apps (unfold, corsheaders, DRF, the 3 local apps, `django_celery_beat`), CORS allowlist (+ custom `x-broadcast-access-code` header), `APPEND_SLASH=False`, Celery/Redis config, unfold admin.
+- `base.py` — shared: installed apps (unfold, corsheaders, DRF, the 5 local apps — `accounts`, `events`, `newsletter`, `ingestion`, `broadcast`; `devtools` is added by `dev.py` only — `django_celery_beat`), CORS allowlist (+ custom `x-broadcast-access-code` header), `APPEND_SLASH=False`, Celery/Redis config, unfold admin.
- `dev.py` — `DEBUG=True`, parses `DATABASE_URL` (Neon dev branch), console email, `BROADCAST_AUTOSPAWN_WORKER=true` by default.
- `prod.py` — `DEBUG=False`; requires `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS`, `DATABASE_URL`.
- `test.py` — inherits dev; strips `-pooler` from the DB host (Neon direct endpoint so the test DB can be created/dropped), eager Celery, locmem cache, stubbed external creds. See [§Testing](#testing--ci).
diff --git a/DEPLOY.md b/DEPLOY.md
index 3aef808..f5fdfcd 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -1,11 +1,20 @@
# Deployment Runbook — The Commons
This is the single source of truth for deploying The Commons to the production VM.
-Follow **Part 1** top-to-bottom for the **first deploy** of the new
-Redis/Celery/broadcast/CI infrastructure (the VM does not have it yet). After that,
+The stack is **containerized** (Docker Compose) — this replaces the old systemd-unit
+runbook. Follow **Part 1** top-to-bottom for the **one-time VM prep** that gates the
+first automated container deploy (the VM does not have Docker on it yet). After that,
deploys are automatic — see **Part 2**. **Part 3** is reference (services, env vars,
nginx, firewall, troubleshooting).
+> ⚠️ **Every prod Compose command in this document passes `-f docker-compose.yml`
+> explicitly, and so must you.** A bare `docker compose` (no `-f`) auto-loads
+> `docker-compose.override.yml` from the same directory — that file is **local-dev
+> only** (plain HTTP nginx with no cert, repo-relative bind-mount paths, `DJANGO_ENV=dev`).
+> Running it against the VM would deploy the dev config to prod: no TLS, wrong
+> hostnames, wrong Redis auth. If you ever type `docker compose` without `-f
+> docker-compose.yml` on the VM, stop and re-type it.
+
---
## Facts you need first
@@ -16,534 +25,696 @@ nginx, firewall, troubleshooting).
| VM IP | `129.80.229.41` |
| SSH | `ssh -i oraclevps.key ubuntu@129.80.229.41` (key is in repo root — **never commit it**) |
| Repo path on VM | `/home/ubuntu/thecommons` |
-| Deploy user | `ubuntu` (all services run as `ubuntu`) |
-| Python | **`uv`** at `/snap/bin/uv` — never `pip` — but **only for one-shot commands** (`uv sync`, `manage.py migrate`, etc.); long-lived systemd units exec `.venv/bin/` directly, never `uv run` (see §4) |
-| Node | **`pnpm`** — never `npm install` (it breaks peer-dependency pinning) |
-| DNS / TLS | Cloudflare, proxied (orange cloud), SSL mode **Full (strict)**; origin cert at `/etc/ssl/cloudflare/thecommons.town.{pem,key}` |
-
-> **What changed since `main`:** this release adds Redis, a Celery worker + beat
-> scheduler, the broadcast worker (Playwright), a healthcheck, and a CI/CD
-> auto-deploy. The ingestion and weekly-digest jobs **moved off OS cron** onto
-> `django-celery-beat` (a DB-backed scheduler seeded by migrations). If you followed
-> any older notes that set up `crontab`/`logrotate`/`/var/log/thecommons` for those
-> two jobs, that approach is **dead** — Part 1 §9 retires it.
+| Deploy user | `ubuntu` (member of the `docker` group; every container runs as `ubuntu`'s Docker daemon, unprivileged) |
+| Container runtime | Docker Engine + the `docker compose` v2 plugin (arm64). **No `sudo` in the deploy path** — group membership is enough. |
+| Python / Node on the VM | **Not used directly anymore.** `uv` and `pnpm` still matter for local dev and CI (they build the images), but the VM never runs `uv sync`, `manage.py migrate` bare, or `pnpm build` outside a container — see Part 2. |
+| DNS / TLS | Cloudflare, proxied (orange cloud), SSL mode **Full (strict)**; origin cert at `/etc/ssl/cloudflare/thecommons.town.{pem,key}`, bind-mounted read-only into the `nginx` container |
+
+> **What changed since the last runbook:** the seven systemd units (`gunicorn`,
+> `nextjs`, `redis-server`, `celery`, `celerybeat`, `broadcast-worker`,
+> `scrape-worker`) plus the hand-edited nginx config are replaced by
+> [`docker-compose.yml`](docker-compose.yml) — one file describing the whole
+> service graph, built and run with `docker compose`. `git pull` + `uv sync` +
+> `pnpm build` + `sudo systemctl restart …` is gone from the deploy path entirely;
+> CI now runs `docker compose -f docker-compose.yml build` and
+> `docker compose -f docker-compose.yml up -d`. See
+> [`docs/adr/0001-containerization.md`](docs/adr/0001-containerization.md) for the
+> full rationale behind each decision (nginx-in-a-container, Redis-in-a-container,
+> Postgres staying external on Neon, what's baked vs. volumed).
---
-# Part 1 — First deploy (manual, one time)
-
-Do these in order. The CI auto-deploy in Part 2 **cannot** succeed until this is
-done, because it restarts `celery`/`celerybeat`/`broadcast-worker`/`scrape-worker`,
-which don't exist on the box yet.
-
-## 1. Pull the new code onto the VM
+# Part 1 — One-time VM prep (blocking)
+
+**This gates the first automated container deploy.** The `deploy` job in
+[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs `docker compose -f
+docker-compose.yml build` and `up -d` on every push to `main` — that fails
+immediately if the VM doesn't have Docker, the `ubuntu` user can't run it without
+`sudo`, or the bind-mount directories/env files it depends on don't exist. Do all
+eleven steps in order before the first push to `main` after this cutover.
+
+> ⚠️ **If you check a feature branch out on the VM to rehearse this before
+> merging, expect an immediate 500 on `api.thecommons.town`.** The systemd
+> gunicorn is still running and imports Python modules lazily *from the working
+> tree*, so a `git checkout` swaps files underneath a process whose settings are
+> already loaded in memory. A branch carrying an app-layout change (the suite-41
+> `accounts`/`newsletter` extraction is the live example) then produces
+> `RuntimeError: Model class accounts.models.BetterAuthUser doesn't declare an
+> explicit app_label and isn't in an application in INSTALLED_APPS` — new file,
+> old `INSTALLED_APPS`. `sudo systemctl restart gunicorn` immediately after the
+> checkout reloads both consistently and clears it. Restart the Celery units too
+> if the branch moved any task modules. None of this applies once the stack is
+> containerized: an image is a snapshot, so a checkout can't change code out from
+> under a running container.
+
+## 1. Install Docker Engine + the Compose v2 plugin (arm64)
```bash
ssh -i oraclevps.key ubuntu@129.80.229.41
-cd /home/ubuntu/thecommons
-git fetch origin
-git checkout testing+ci && git pull origin testing+ci
+sudo apt-get update
+sudo apt-get install -y ca-certificates curl
+sudo install -m 0755 -d /etc/apt/keyrings
+sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
+sudo chmod a+r /etc/apt/keyrings/docker.asc
+
+echo \
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
+ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+sudo apt-get update
+
+sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
-> You provision from the `testing+ci` branch now. In §10 you'll switch the VM to
-> `main` so CI's `git pull` tracks the right branch going forward.
+`dpkg --print-architecture` resolves to `arm64` automatically on this VM — no
+manual platform pin needed; every image in this stack (`python:3.13-slim`,
+`node:22-slim`, `redis:7-alpine`, `nginx:1.27-alpine`, `postgres:18-alpine`) ships
+a maintained arm64 build (`docs/adr/0001-containerization.md` §Base images).
-## 2. Provision Redis (one time)
+Verify:
```bash
-sudo apt update && sudo apt install -y redis-server
-openssl rand -hex 32 # copy the output — this is
-sudo nano /etc/redis/redis.conf # set the four lines below
+docker --version
+docker compose version # the `compose` SUBCOMMAND must work — that's the plugin, not the old standalone `docker-compose` binary.
+ # Don't assert a specific major here; what matters is that `docker compose` (space, not hyphen)
+ # resolves at all. Verified installed on this VM: Docker 29.7.1 / Compose v5.3.1.
```
-In `/etc/redis/redis.conf` set:
+## 2. Add `ubuntu` to the `docker` group — and prove it non-interactively
-```
-bind 127.0.0.1 -::1
-requirepass
-maxmemory 512mb
-maxmemory-policy allkeys-lru
+```bash
+sudo usermod -aG docker ubuntu
```
+**Group membership only applies to sessions started *after* this command runs.**
+The SSH session you just ran `usermod` in still has the old group list — testing
+`docker ps` in that same session (or papering over it with `newgrp docker`) proves
+nothing about how CI will actually connect. CI's `appleboy/ssh-action` opens a
+brand-new non-interactive SSH connection on every run (`ssh host 'command'`, not an
+interactive login shell), so that's exactly what you must test:
+
```bash
-sudo systemctl enable --now redis-server && sudo systemctl restart redis-server
-redis-cli -a '' PING # → PONG
+exit # close the current SSH session completely
+ssh -i oraclevps.key ubuntu@129.80.229.41 'id -nG; docker version --format "{{.Server.Version}}"'
```
-One Redis instance, two logical DBs: **DB 0** = Celery broker + results, **DB 1** =
-read-endpoint cache. The password lives **only** in `backendServer/.env`, never in git.
-
-## 3. Backend — env, deps, migrate, static
+`id -nG` must list `docker`, and the second command must print a server version
+with **no `sudo` and no permission-denied error** on the Docker socket. If it
+fails, the group add didn't take (check `getent group docker`) or the session
+really is stale — reconnect fresh and retry.
-Edit `backendServer/.env` and add the Redis URLs (note the **leading `:`** and no
-username). Full env reference is in Part 3.
+## 3. Create the persistent host directories (bind mounts)
-```
-REDIS_URL=redis://:@127.0.0.1:6379/0
-REDIS_CACHE_URL=redis://:@127.0.0.1:6379/1
-INGEST_SHARD_COUNT=3 # optional — spreads source polling across 3 days; set 1 or omit to poll all daily
+```bash
+mkdir -p /home/ubuntu/broadcast/{media,screenshots,downloads} /home/ubuntu/backups
```
-**`INGEST_SHARD_COUNT=3` is a deliberate prod setting — keep it at 3.** `_resolve_env_shard`
-(`backendServer/ingestion/tasks.py:22-39`) polls only the sources where
-`id % INGEST_SHARD_COUNT == day_of_year % INGEST_SHARD_COUNT`, so with a shard count
-of 3 any given source is actually polled roughly every **72 hours**, not the 24h its
-own `poll_interval_hours` implies — sharding is disabled (all sources polled daily)
-only when the var is unset, fails to parse as an int, or is `<= 1`. Two consequences
-to keep in mind:
+These back real state that must survive a container restart — client-uploaded
+event images, Playwright debug artifacts, and pre-migrate `pg_dump`s
+(`docs/adr/0001-containerization.md` §Decision 4). `docker-compose.yml` bind-mounts
+them at the identical absolute path inside the `backend`, `broadcast-worker`,
+`scrape-worker`, `migrate`, and `nginx` containers.
-- A source that fails transiently gets **1/3 as many chances** to recover before the
- next poll compared to unsharded daily polling.
-- Monitoring/alerting thresholds must be shard-aware — a source that looks "3 days
- stale" is expected and healthy under this setting, not a symptom of an outage. (A
- companion ticket is making the ingestion monitor account for this.)
+They must be **writable by uid 1000** — the image's non-root `app` user
+(`backendServer/Dockerfile`: `useradd --uid 1000 --gid app …`) — **and** by the
+host's `ubuntu` user, which is what the deploy pipeline runs as.
-```bash
-cd /home/ubuntu/thecommons/backendServer
-/snap/bin/uv sync
-/snap/bin/uv run python manage.py migrate # also seeds the beat schedules
-/snap/bin/uv run python manage.py collectstatic --noinput
-```
-
-The `migrate` step runs `ingestion/migrations/0007_seed_ingest_beat.py` (ingest,
-04:00 ET daily) and `events/migrations/0015_seed_digest_beat.py` (digest, Sun
-18:00 ET), which create the scheduled tasks in the database.
-
-## 4. Celery worker + beat services (one time)
-
-**The `uv` rule for this project has two cases — don't collapse them:**
-
-- **One-shot commands** (`uv sync`, `uv run python manage.py migrate`, `playwright
- install`, etc.) — always go through `/snap/bin/uv`. That part of the Facts table
- above is unchanged.
-- **Long-lived systemd units** (`celery`, `celerybeat`, `scrape-worker`,
- `broadcast-worker`) — **never** `uv run`. They exec the venv binary directly,
- `/home/ubuntu/thecommons/backendServer/.venv/bin/celery …`, the same pattern
- `gunicorn` has always used (`.venv/bin/gunicorn`). This is not a style preference:
- snap-packaged `uv run` spawns its child process inside a transient
- `snap.astral-uv.uv-*.scope` under `user@1001.service` — the *user* manager, not the
- unit's own cgroup. With `Linger=no`, systemd-logind tears down that user slice the
- moment the deploying SSH session ends, and the child (celery/beat) receives SIGTERM
- and performs a clean warm shutdown — **`status=0/SUCCESS`**, which
- `Restart=on-failure` correctly declines to restart. That is exactly how the async
- stack died silently on 2026-07-21 12:46 UTC and stayed down for 8 days while the
- site looked healthy: every `uv run`-based unit died within a minute of the deploy
- session ending; `gunicorn` (execs its venv binary) and `nextjs` (`/usr/bin/npm`)
- never went through snap and stayed up 68 days straight. See
- `docs/prod-incident-2026-07-21-scheduler-outage.md` for the full forensics.
-
-Two complementary mitigations are now in place, not alternatives — keep both:
-
-1. **`.venv/bin/celery` in `ExecStart`** (all four unit files) removes the mechanism
- entirely — the process never lands in a user-manager slice, so there's nothing for
- a lost SSH session to tear down.
-2. **`Restart=always`** (all four units) + **linger enabled for `ubuntu`** are
- defense-in-depth, in case a unit is ever reverted to `uv run` or linger is somehow
- turned back off:
- ```bash
- sudo loginctl enable-linger ubuntu
- loginctl show-user ubuntu --property=Linger # must print Linger=yes
- ```
+> ⚠️ **On this VM `ubuntu` is uid 1001, not 1000.** The Oracle Ubuntu image ships
+> an `opc` user that already holds uid 1000 (`getent passwd 1000 1001` to see it).
+> So a plain `sudo chown -R 1000:1000` — the obvious reading of "writable by uid
+> 1000" — hands these directories to `opc` and locks `ubuntu` *out* of them. That
+> silently breaks the backup-pruning step in Part 2 §4
+> (`ls -1t /home/ubuntu/backups/… | xargs rm`, which runs as `ubuntu`), while the
+> containers themselves stay perfectly happy — a failure that only shows up as
+> backups quietly accumulating forever. Don't assume; check.
+
+Give the container the ownership and the host user the group, with setgid so
+newly created files keep inheriting that group:
```bash
-cd /home/ubuntu/thecommons
-sudo cp deploy/celery.service deploy/celerybeat.service /etc/systemd/system/
-sudo systemctl daemon-reload
-sudo systemctl enable --now celery celerybeat
-sudo systemctl status celery celerybeat # both active (running)
-sudo journalctl -u celery -n 30 # should show "celery@... ready" + broker connected
+getent passwd 1000 1001 # confirm who actually holds each uid
+sudo chown -R 1000:1001 /home/ubuntu/broadcast /home/ubuntu/backups
+sudo chmod -R 2775 /home/ubuntu/broadcast /home/ubuntu/backups
```
-Run **exactly one** beat process. The worker drains Redis DB 0; beat uses
-`django-celery-beat`'s DatabaseScheduler, so schedules are editable later in the
-Django admin.
-
-**The real acceptance test is a full SSH login→logout cycle, not `systemctl
-is-active` right after `enable --now`.** A unit that was just started by your current
-session is active regardless of whether the underlying bug is fixed — that's the
-exact false-positive that let this incident go unnoticed for 8 days. Log out
-completely and reconnect fresh before you trust the result:
+That leaves uid 1000 (the container's `app` user) as **owner**, and gid 1001
+(`ubuntu`) as a **group** with write — so both sides can write the same trees. Use
+numeric ids rather than names: the container's `app` user is fixed at uid 1000
+regardless of what the host calls that uid. Verify both directions before moving
+on:
```bash
-exit # close the SSH session completely
-ssh -i oraclevps.key ubuntu@129.80.229.41 'systemctl is-active celery celerybeat scrape-worker broadcast-worker'
-# all four must print "active" from a session that did NOT start them
+touch /home/ubuntu/broadcast/media/.wtest && rm /home/ubuntu/broadcast/media/.wtest # host `ubuntu`
+docker run --rm -u 1000:1000 -v /home/ubuntu/broadcast/media:/m alpine \
+ sh -c 'touch /m/.ctest && rm /m/.ctest' # container uid 1000
```
-Confirm every queue has a consumer. A queue with depth but no consumer is
-silent — jobs enqueue and are never dispatched:
+## 4. Confirm the Cloudflare origin cert is in place
```bash
-cd /home/ubuntu/thecommons/backendServer
-uv run celery -A backend inspect active_queues | grep -o "'name': '[a-z]*'" # expect celery, scrape, broadcast
+ls -l /etc/ssl/cloudflare/thecommons.town.pem /etc/ssl/cloudflare/thecommons.town.key
```
-## 5. Broadcast feature (one time)
+The `nginx` container bind-mounts `/etc/ssl/cloudflare` read-only
+(`docker-compose.yml`'s `nginx.volumes`) — it is **never** baked into the image
+(a rebuild-and-repush on every cert rotation, and the private key ending up in a
+pullable image layer, are exactly what a bind mount avoids —
+`docs/adr/0001-containerization.md` §Decision 1). If this cert isn't already on the
+box from the earlier broadcast-subdomain rollout, reissue a
+`thecommons.town, *.thecommons.town` origin cert in the Cloudflare dashboard and
+place it here before continuing — `nginx` refuses to boot with `listen 443 ssl`
+blocks pointing at missing files.
-The broadcast feature adds a third subdomain, a Playwright worker, and a static SPA.
-Do all seven in order.
-
-> **Local dev note:** `BROADCAST_AUTOSPAWN_WORKER` no longer exists — Suite 25 moved
-> broadcast dispatch onto on-demand Celery (`transaction.on_commit(process_broadcast_queue.delay)`),
-> so there's nothing to autospawn. To drain broadcast jobs locally, either run
-> `celery -A backend worker -Q broadcast -c 1 -l info` alongside `runserver`, or set
-> `CELERY_TASK_ALWAYS_EAGER=True` (as `settings/test.py` does) to execute tasks
-> synchronously with no worker at all. See [`docs/broadcast.md`](docs/broadcast.md).
-
-1. **TLS (do this first).** The existing origin cert covers only `thecommons.town`
- and `api.thecommons.town`. In the Cloudflare dashboard, reissue an origin cert
- for `thecommons.town, *.thecommons.town`, replace
- `/etc/ssl/cloudflare/thecommons.town.{pem,key}`, then:
- ```bash
- sudo nginx -t && sudo systemctl reload nginx
- ```
- Skipping this makes the subdomain fail with a Cloudflare 526 error.
-2. **DNS.** Cloudflare → add an **A record** `broadcast` → `129.80.229.41`, proxied
- (orange cloud). An A record, not a CNAME.
-3. **Playwright Chromium** (bundled only — arm64 has no branded Chrome):
- ```bash
- cd /home/ubuntu/thecommons/backendServer
- /snap/bin/uv run playwright install chromium
- /snap/bin/uv run playwright install-deps chromium # apt system libs (uses sudo)
- ```
-4. **Artifact dirs:**
- ```bash
- mkdir -p /home/ubuntu/broadcast/{screenshots,downloads}
- ```
-5. **Env.** Add the `BROADCAST_*` block from `backendServer/.env.example` to
- `backendServer/.env`, and append `https://broadcast.thecommons.town` to both
- `CORS_EXTRA_ORIGINS` and `CSRF_TRUSTED_ORIGINS`.
-6. **Worker service.** Broadcast dispatch is on-demand Celery, not a poll loop:
- the app calls `transaction.on_commit(process_broadcast_queue.delay)` on
- submit/retry, routed to a dedicated `broadcast` Celery queue
- (`CELERY_TASK_ROUTES` in `backend/settings/base.py`). `deploy/broadcast-worker.service`
- runs `celery -A backend worker -Q broadcast -c 1 -l info` — **`-c 1` is
- mandatory, not a tuning default**: `recover_orphans()` assumes any `running`
- submission at startup is orphaned, so a second concurrent worker could race
- a live queue-drain. A `broadcast-orphan-recovery` beat task (seeded by
- migration `0009_seed_orphan_recovery_beat.py`) sweeps orphaned submissions
- every 6 hours as a crash-recovery net; normal stalled-target recovery is
- client-driven from the SPA (`POST /broadcast/jobs//retry-stuck`) and
- doesn't involve this worker restarting.
- ```bash
- cd /home/ubuntu/thecommons
- sudo cp deploy/broadcast-worker.service /etc/systemd/system/
- sudo systemctl daemon-reload && sudo systemctl enable --now broadcast-worker
- ```
-7. **nginx.** Add the server block from `deploy/nginx-broadcast.conf.snippet` into
- the **existing** `/etc/nginx/sites-available/thecommons` (one file, many `server`
- blocks — do not create a new sites-available file), then:
- ```bash
- sudo nginx -t && sudo systemctl reload nginx
- ```
-
-## 6. Scrape worker (one time)
-
-The ingestion scraper (`ingestion.tasks.scrape_all_sources_task`) renders headless
-Chromium and is routed to a dedicated `scrape` Celery queue (`CELERY_TASK_ROUTES` in
-`backend/settings/base.py`) so its memory never lands on the default `celery`
-worker. `deploy/celery.service`'s `ExecStart` has no `-Q` flag (`celery -A backend
-worker -l info --concurrency=2`), so the default worker only drains the `celery`
-queue — it will **not** pick up `scrape` tasks. Without this dedicated worker,
-scrape tasks queue forever.
-
-1. **Playwright Chromium** (skip if already installed for broadcast — same shared
- cache):
- ```bash
- cd /home/ubuntu/thecommons/backendServer
- /snap/bin/uv run playwright install chromium
- /snap/bin/uv run playwright install-deps chromium # apt system libs (uses sudo)
- ```
- Bundled Chromium only — **never** a branded "chrome" channel, unsupported on
- arm64. It installs into `/home/ubuntu/.cache/ms-playwright/`, shared with
- `broadcast-worker`, so there's no extra download if that's already in place.
-2. **Env.** Add the three `INGEST_SCRAPER_*` vars to `backendServer/.env` if you
- want non-default values (all optional — see Part 3 for defaults).
-3. **Worker service:**
- ```bash
- cd /home/ubuntu/thecommons
- sudo cp deploy/scrape-worker.service /etc/systemd/system/
- sudo systemctl daemon-reload && sudo systemctl enable --now scrape-worker
- sudo systemctl status scrape-worker # active (running)
- ```
-
-> ⚠️ **Operator ordering matters.** From this point on, `.github/workflows/ci.yml`'s
-> `deploy` job restarts `scrape-worker` on every push to `main` (see §10/Part 2).
-> If you haven't installed and `systemctl enable`d the unit (and run `playwright
-> install chromium`) **before** the first deploy that includes this change, CI's
-> `sudo -n systemctl restart ... scrape-worker` step will fail and block the
-> deploy. Do steps 1–3 above first.
-
-## 7. Frontend builds + restart
+## 5. Confirm the three runtime env files exist on the box
```bash
-cd /home/ubuntu/thecommons/theCommonsWeb
-pnpm install && pnpm run build
-sudo systemctl restart nextjs
+ls -l /home/ubuntu/thecommons/backendServer/.env \
+ /home/ubuntu/thecommons/theCommonsWeb/.env.local \
+ /home/ubuntu/thecommons/broadcastWeb/.env
+```
+
+These are consumed two different ways, and it matters which:
+
+- **`env_file:`** in `docker-compose.yml` (all Django/Celery services, plus
+ `nextjs`) loads them into the container's runtime environment when it starts.
+- **Build args** (`nextjs` and `broadcast-spa-build`'s `NEXT_PUBLIC_*`/`VITE_*`
+ values) get baked into the compiled JS at *build* time via Compose variable
+ interpolation, which only reads the shell environment or a `.env` file next to
+ `docker-compose.yml` — it **cannot** read `theCommonsWeb/.env.local` or
+ `broadcastWeb/.env` directly. Part 2 covers how the deploy pipeline bridges this
+ (`source` the real files, re-export under the `NEXTJS_BUILD_*`/`BROADCAST_BUILD_*`
+ names the build args read).
+
+If any of the three files is missing, create it from its `.env.example` and fill
+in real values before proceeding — every other step assumes they're already
+correct except step 6, which calls out the one change that's mandatory.
+
+> ⚠️ **Quote any value containing `&`, a space, or `#`.** The build path
+> (`set -a; . theCommonsWeb/.env.local`) is **shell sourcing, not a dotenv
+> parser** — it does not reject a malformed line, it *mis-parses* it. A bare
+> Neon URL is the live example, because its query string contains `&`:
+>
+> ```
+> DATABASE_URL=postgres://…?sslmode=require&channel_binding=require # BROKEN
+> DATABASE_URL='postgres://…?sslmode=require&channel_binding=require' # correct
+> ```
+>
+> The shell reads `VAR=x & y=z` as "assign `x` in a **background subshell**,
+> then assign `y`", so `DATABASE_URL` never reaches the calling shell. Compose's
+> `env_file:` parses the same file correctly, so the running containers look
+> fine — only the *build args* are wrong, and compose's `${VAR:-placeholder}`
+> defaults turn that into a build that succeeds with placeholder config baked
+> in. `backendServer/.env` already quotes its `DATABASE_URL`;
+> `theCommonsWeb/.env.local` did not, which is what surfaced this.
+>
+> Audit all three files at once — this compares each key's literal value against
+> what sourcing actually yields, and prints no secrets:
+>
+> ```bash
+> cd /home/ubuntu/thecommons
+> for f in backendServer/.env theCommonsWeb/.env.local broadcastWeb/.env; do
+> echo "=== $f ==="
+> awk '/^[A-Za-z_][A-Za-z0-9_]*=/ {k=substr($0,1,index($0,"=")-1); v=substr($0,index($0,"=")+1);
+> if ((substr(v,1,1)=="\"" && substr(v,length(v),1)=="\"") || (substr(v,1,1)=="'"'"'" && substr(v,length(v),1)=="'"'"'")) v=substr(v,2,length(v)-2);
+> print k"\t"length(v)}' "$f" > /tmp/lit
+> ( set -a; . "$f" >/dev/null 2>&1; set +a
+> while IFS=$'\t' read -r k n; do eval "cur=\${$k-__UNSET__}"
+> [ "$cur" = "__UNSET__" ] && { echo " $k BROKEN (unset after sourcing)"; continue; }
+> [ "${#cur}" = "$n" ] || echo " $k TRUNCATED ($n -> ${#cur})"
+> done < /tmp/lit )
+> done
+> ```
+>
+> The `deploy` job also fails loudly now if any required build arg comes out
+> empty (`.github/workflows/ci.yml`), but fix the file rather than relying on
+> that backstop.
+
+## 6. ⚠️ Blocking: point `backendServer/.env` at the container Redis, not localhost
+
+**This is the step this whole suite exists to get right — treat it as a release
+blocker, not a nit.** `backendServer/.env` almost certainly still has:
-cd ../broadcastWeb
-pnpm install && pnpm run build # static → dist/, served directly by nginx (no service)
-
-sudo systemctl restart gunicorn
+```
+REDIS_URL=redis://:@127.0.0.1:6379/0
+REDIS_CACHE_URL=redis://:@127.0.0.1:6379/1
```
-## 8. Verify
+Inside a container, `127.0.0.1`/`localhost` is *that container itself* — Redis now
+runs in its own container reachable only by its Compose service name. Leaving
+these as-is does **not** error at startup: Celery just silently connects to
+nothing and no task ever runs, while gunicorn, nginx, and everything synchronous
+stays green. That is the exact shape of the 2026-07-21 outage (async stack dead,
+everything else looked healthy) — see §Historical incident below. Fix it:
+
+```
+REDIS_URL=redis://:@redis:6379/0
+REDIS_CACHE_URL=redis://:@redis:6379/1
+```
+
+The `` embedded in both URLs must match a `REDIS_PASSWORD` entry
+you add to the same file — the `redis` container reads `REDIS_PASSWORD` directly
+(`docker-compose.yml`'s `redis.command`) and starts `--requirepass` with it; Django
+and Celery never read `REDIS_PASSWORD` itself, only the URLs above, which already
+carry the password in their `:@` segment. If it isn't already present:
+
+```
+REDIS_PASSWORD= # same value embedded in the two URLs above
+```
+
+While you're in this file, confirm two more things `prod.py` hard-requires —
+missing either crashes every container on boot, not just Celery:
+
+- **`DJANGO_ALLOWED_HOSTS`** is set (`backend/settings/prod.py:16` does
+ `os.environ["DJANGO_ALLOWED_HOSTS"]` — a hard `KeyError`, not a default, if it's
+ absent). Expected value: `localhost,127.0.0.1,api.thecommons.town`.
+ `docker-compose.override.yml` (local dev only) sets `DJANGO_ENV=dev` specifically
+ to dodge this same crash on a laptop that has no reason to set this var — that
+ workaround does not apply on the VM.
+- **`DJANGO_ENV=prod`** is set. `docker-compose.yml` also sets it via
+ `environment: DJANGO_ENV: prod` on every backend/Celery service, so this is
+ belt-and-suspenders — but a stray unset/wrong value in `.env` that later
+ overrides it (env_file loads before `environment:` in Compose precedence,
+ so `environment:` wins) is not worth relying on. Confirm it's `prod` in the file
+ too.
+
+`manage.py healthcheck` pings Redis DB 0 and round-trips DB 1, so the hourly
+watchdog (Part 3 §Health check) will eventually catch a missed edit here — but the
+point of calling this out as its own step is to not discover it that way.
+
+> ⚠️ **This edit breaks the still-running host stack the moment you save it.**
+> Steps 7–9 haven't happened yet, so the *systemd* gunicorn/celery are still
+> serving production out of this same `.env` — and on the host there is no such
+> hostname as `redis`, so every cached read starts throwing
+> `redis.exceptions.ConnectionError: Error -3 connecting to redis:6379.
+> Temporary failure in name resolution` and `api.thecommons.town/events/` 500s.
+> The step reads like inert preparation; it isn't. Bridge it before you edit, so
+> the host stack keeps resolving `redis` to the local `redis-server` until the
+> containers take over:
+>
+> ```bash
+> grep -q '^127.0.0.1[[:space:]]\+redis\b' /etc/hosts || echo '127.0.0.1 redis' | sudo tee -a /etc/hosts
+> sudo systemctl restart gunicorn celery celerybeat broadcast-worker scrape-worker
+> ```
+>
+> Containers are unaffected either way — they resolve `redis` through Docker's
+> embedded DNS inside their own namespace and never consult the host's
+> `/etc/hosts`. **Remove the line once step 9 has retired the host units**
+> (`sudo sed -i '/^127\.0\.0\.1[[:space:]]\+redis$/d' /etc/hosts`); leaving it
+> behind is harmless but will mislead whoever debugs this box next.
+
+## 7. First manual bring-up — everything except `nginx`
+
+Bring the non-ingress services up by hand once, before CI ever touches the box,
+so you can see them start clean without racing the host-nginx cutover in the next
+step:
```bash
cd /home/ubuntu/thecommons
-UV_BIN=/snap/bin/uv bash deploy/healthcheck.sh # everything should be ✓
+docker compose -f docker-compose.yml up -d --build redis migrate backend celery celerybeat broadcast-worker scrape-worker nextjs
+docker compose -f docker-compose.yml ps
```
-Then smoke-test the two scheduled jobs by hand (they still exist as manual triggers):
+`migrate` is a one-shot container (`restart: "no"`) — it applies migrations
+(seeding the `django_celery_beat` schedule tables in the process) and exits 0;
+`docker compose ps` will show it `Exited (0)`, which is correct, not a failure.
+Every other service listed should show `running`. Then run the full report:
```bash
-sudo -u ubuntu bash -lc 'cd /home/ubuntu/thecommons/backendServer && /snap/bin/uv run python manage.py ingest_events --shard 0/3 --skip-standardize --skip-dedup --skip-safety --skip-autopublish'
-sudo -u ubuntu bash -lc 'cd /home/ubuntu/thecommons/backendServer && /snap/bin/uv run python manage.py send_test_digest --email aryav@unc.edu'
-curl -I https://broadcast.thecommons.town/ # expect 200/3xx
+bash deploy/healthcheck.sh
```
-If the broadcast `curl` hangs, it's the iptables REJECT-before-ACCEPT gotcha — see
-Part 3 §Firewall.
+Expect `redis`, `backend`, `celery`, `celerybeat`, `broadcast-worker`, and
+`scrape-worker` all `✓`, plus the app-level Redis/Postgres/Celery-ping/beat-schedule
+checks from `manage.py healthcheck` running *inside* the `backend` container.
+`nextjs` and `nginx` will `✗` here — `nginx` hasn't been started yet (next step),
+and that's expected at this point.
-## 9. Retire the old OS cron (only if it exists)
+## 8. Cutover: stop the host nginx, start the container nginx
-Beat now owns the ingest + digest schedules. If the box still has the old cron
-lines, remove them so the jobs don't run twice:
+**This is the one step in the whole suite where a mistake produces an immediate,
+visible outage** (`docs/adr/0001-containerization.md` §Decision 1) — two processes
+cannot bind ports 80/443 at once, so the container nginx cannot come up cleanly
+until the host one is out of the way.
```bash
-crontab -l # look for ingest_events / send_weekly_digest lines
-crontab -e # delete those two lines if present
+sudo systemctl disable --now nginx
+docker compose -f docker-compose.yml up -d --build nginx
+docker compose -f docker-compose.yml ps
```
-`healthcheck.sh` also flags leftover cron lines for these jobs.
-
-## 10. Switch the VM to `main` and enable CI/CD
-
-**On your laptop** — merge and push:
+Verify all three domains from outside the box:
```bash
-git checkout main && git merge testing+ci && git push origin main
+curl -I https://thecommons.town/
+curl -I https://api.thecommons.town/events/
+curl -I https://broadcast.thecommons.town/
```
-**On the VM** — point it at `main` so CI's `git pull` works:
+If `broadcast.thecommons.town` 526s, the origin cert doesn't cover the wildcard —
+back to step 4. If any of the three hangs, it's the iptables
+REJECT-before-ACCEPT gotcha — see Part 3 §Firewall (unchanged by containerization:
+the container nginx still binds the host's 80/443 the same way the old one did).
-```bash
-cd /home/ubuntu/thecommons && git checkout main && git pull
-```
+## 9. Retire the old systemd units and the sudoers drop-in
-**Deploy SSH key (laptop):**
+Now that the containers own gunicorn, Next.js, and all four Celery roles, the old
+units would only cause confusion (or a port clash) if left enabled:
```bash
-ssh-keygen -t ed25519 -C "github-actions-deploy@thecommons" -f ~/.ssh/thecommons_deploy -N ""
-cat ~/.ssh/thecommons_deploy.pub | ssh -i oraclevps.key ubuntu@129.80.229.41 \
- 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
-ssh -i ~/.ssh/thecommons_deploy ubuntu@129.80.229.41 'echo deploy-key-ok'
+sudo systemctl disable --now celery celerybeat broadcast-worker scrape-worker gunicorn nextjs
+sudo rm -f /etc/systemd/system/{celery,celerybeat,broadcast-worker,scrape-worker,gunicorn,nextjs}.service
+sudo systemctl daemon-reload
+sudo rm -f /etc/sudoers.d/deploy-restart
```
-**Sudoers drop-in (VM)** — passwordless restart for exactly the six units:
+Leave `redis-server` alone if it's still `apt`-installed from before — stop and
+disable it too, since the `redis` container now owns port 6379:
```bash
-sudo visudo -f /etc/sudoers.d/deploy-restart
+sudo systemctl disable --now redis-server 2>/dev/null || true
```
-```
-ubuntu ALL=(root) NOPASSWD: /usr/bin/systemctl restart gunicorn, \
- /usr/bin/systemctl restart nextjs, \
- /usr/bin/systemctl restart celery, \
- /usr/bin/systemctl restart celerybeat, \
- /usr/bin/systemctl restart broadcast-worker, \
- /usr/bin/systemctl restart scrape-worker
-```
+`healthcheck.service`/`healthcheck.timer` are **not** part of this retirement —
+they were rewritten to check containers (`docker compose ps` / `docker inspect`)
+rather than `systemctl is-active`, and still run as a host-level systemd timer on
+purpose (Part 3 §Health check explains why). Leave them installed, or install them
+now if this is the first time — see that section.
+
+## 10. Confirm the GitHub Actions secrets
-**GitHub repo secrets** (Settings → Secrets and variables → Actions) — all four:
+The deploy job only needs the four secrets it already had — nothing new, since
+Docker commands run unprivileged via the `docker` group instead of the old
+`systemctl`-restart sudoers allowlist:
| Secret | Value |
|--------|-------|
-| `ORACLE_SSH_KEY` | Full PEM **private** key (`~/.ssh/thecommons_deploy`, incl. BEGIN/END lines) |
+| `ORACLE_SSH_KEY` | Full PEM **private** key for the deploy key (incl. BEGIN/END lines) |
| `ORACLE_HOST` | `129.80.229.41` (raw IP — Cloudflare won't proxy port 22) |
| `ORACLE_USER` | `ubuntu` |
-| `ORACLE_KNOWN_HOSTS` | The **single** line from `ssh-keyscan -t ed25519 129.80.229.41` |
+| `ORACLE_KNOWN_HOSTS` | Output of `ssh-keyscan 129.80.229.41` (all key types — `.github/workflows/ci.yml`'s fingerprint step needs ECDSA/RSA/ED25519 all present, since `appleboy/ssh-action`'s Go SSH client negotiates ECDSA first) |
-**Confirm the non-interactive PATH** sees the tools CI will call:
+Confirm the deploy key still authenticates and the non-interactive Docker check
+from step 2 still passes through it specifically (not just `oraclevps.key`):
```bash
-ssh -i ~/.ssh/thecommons_deploy ubuntu@129.80.229.41 'command -v uv; command -v pnpm; command -v node'
+ssh -i ~/.ssh/thecommons_deploy ubuntu@129.80.229.41 'id -nG; docker version --format "{{.Server.Version}}"'
```
-If `uv` isn't found, CI uses `/snap/bin/uv` (the workflow already accounts for this).
+## 11. Trigger and verify the first automated deploy
-## 10. Trigger and verify the first automated deploy
-
-The `deploy` job runs only on **push to `main`**, after `backend`,
-`frontend-commons`, and `frontend-broadcast` pass.
+The `deploy` job runs only on **push to `main`**, gated on `backend`,
+`frontend-commons`, and `frontend-broadcast` all passing.
- Push to `main` (or re-run the Action). Watch **Actions → CI**: three green test
jobs → `deploy` starts.
-- The `deploy` log should show each step: `git pull`, `uv sync`, `migrate`,
- `collectstatic`, both `pnpm` builds, the restart, and `is-active` lines all
- printing `active`.
-- Confirm the VM is on the pushed commit:
+- The `deploy` log should show, in order: `git pull`, `docker compose … build` (all
+ services), the broadcast-bundle origin grep check, the guarded-migrate check,
+ `docker compose … up -d`, the running-services assertion, then a separate
+ post-deploy smoke-test job (three domain checks, the rate-limit regression probe,
+ the auth-bridge probes). See Part 2 for what each of those actually does.
+- Confirm the VM is on the pushed commit and every long-running service is up:
```bash
ssh -i ~/.ssh/thecommons_deploy ubuntu@129.80.229.41 \
- 'cd /home/ubuntu/thecommons && git log -1 --oneline'
+ 'cd /home/ubuntu/thecommons && git log -1 --oneline && docker compose -f docker-compose.yml ps'
```
> **Budget 30–60 min for the first automated run.** It usually trips on something
-> environmental (a PATH gap, a sudoers path mismatch, `--frozen-lockfile` drift).
-> Read the failing step's log, fix on the VM or in the workflow, push again.
+> environmental — a bind-mount permission gap, a stale `.env`, `--frozen-lockfile`
+> drift in `uv.lock`/`pnpm-lock.yaml`. Read the failing step's log
+> (`docker compose -f docker-compose.yml logs ` on the VM reproduces most
+> failures locally), fix it, push again.
---
# Part 2 — Ongoing deploys (automatic)
-Once Part 1 is done, **every push to `main`** runs CI (`.github/workflows/ci.yml`)
-and, after all three test jobs pass, a gated `deploy` job SSHes into the VM and runs
-the full sequence: `git pull` → `uv sync` → guarded `migrate` (below) → `collectstatic`
-→ both frontend `pnpm install`/`build` → restart `gunicorn nextjs celery celerybeat
-broadcast-worker scrape-worker`, then a post-deploy smoke test: all three domains, `/events/`,
-auth probes (`/auth/me` with no/garbage token must 401/403 — never 500 — and the
-API-key path must accept the real key), and a broadcast rate-limit regression check.
-A failing test on `main` blocks the deploy.
-
-> **Guarded migrate:** the deploy runs `migrate --check` first and skips `migrate`
-> entirely when nothing is pending. When migrations *are* pending it logs the plan,
-> takes a `pg_dump` to `/home/ubuntu/backups/pre-migrate-.sql.gz` (keeps
-> the 5 newest), and only then applies. It **fails the deploy** if `pg_dump` is
-> missing — one-time VM prep: `sudo apt install -y postgresql-client`. The dump is
-> belt-and-suspenders; Neon PITR/branching is the real restore mechanism.
+Once Part 1 is done, **every push to `main`** runs CI
+(`.github/workflows/ci.yml`) and, after `backend`, `frontend-commons`, and
+`frontend-broadcast` all pass, a gated `deploy` job SSHes into the VM and runs the
+full container build-and-release sequence, followed by a separate post-deploy
+smoke test. A failing test on `main` blocks the deploy entirely — nothing below
+runs until all three test jobs are green.
+
+**On the VM, in order:**
+
+1. **`git pull origin main`** (after discarding any stray tracked
+ `tsconfig.tsbuildinfo` that could block the fast-forward — a leftover from a
+ pre-Docker deploy).
+2. **Build every image**, arm64-native, no registry needed — the VM builds what it
+ runs:
+ ```bash
+ (
+ set -a
+ . theCommonsWeb/.env.local
+ . broadcastWeb/.env
+ set +a
+ export NEXTJS_BUILD_DATABASE_URL="$DATABASE_URL"
+ export NEXTJS_BUILD_BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET"
+ export NEXTJS_BUILD_BETTER_AUTH_URL="$BETTER_AUTH_URL"
+ export NEXTJS_BUILD_NEXT_PUBLIC_BETTER_AUTH_URL="$NEXT_PUBLIC_BETTER_AUTH_URL"
+ export NEXTJS_BUILD_NEXT_PUBLIC_API_BASE_URL="$NEXT_PUBLIC_API_BASE_URL"
+ export NEXTJS_BUILD_NEXT_PUBLIC_THE_COMMONS_API_KEY="$NEXT_PUBLIC_THE_COMMONS_API_KEY"
+ export BROADCAST_BUILD_VITE_BROADCAST_API_BASE_URL="$VITE_BROADCAST_API_BASE_URL"
+ export BROADCAST_BUILD_VITE_BETTER_AUTH_URL="$VITE_BETTER_AUTH_URL"
+ export BROADCAST_BUILD_VITE_BROADCAST_EXTENSION_ID="$VITE_BROADCAST_EXTENSION_ID"
+ docker compose -f docker-compose.yml build
+ )
+ ```
+ This re-export is required because Compose's `${VAR:-default}` interpolation
+ only reads the shell env or a root-level `.env` — it cannot read
+ `theCommonsWeb/.env.local`/`broadcastWeb/.env` directly (see `docker-compose.yml`'s
+ header comment). Skipping it silently ships the safe *placeholder* build-arg
+ defaults instead of real config — a build that "succeeds" but is wrong.
+3. **Guard against exactly that**: grep the built `broadcast-spa-build` bundle for
+ a real `thecommons.town` API origin. A malformed `VITE_BROADCAST_API_BASE_URL`
+ builds fine and misroutes every API call silently — this catches it before the
+ image goes live:
+ ```bash
+ docker compose -f docker-compose.yml run --rm -T --no-deps --entrypoint sh broadcast-spa-build \
+ -c 'grep -rq "https\?://[a-zA-Z0-9.-]*thecommons\.town" /app/dist/assets/*.js'
+ ```
+4. **Guarded migrate.** `migrate --check` exits non-zero only when unapplied
+ migrations exist, and applies nothing itself — so prod only ever writes schema
+ when there's real work, and never without a fresh dump first:
+ ```bash
+ docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py migrate --check
+ ```
+ If that fails (migrations pending): take a `pg_dump` via a throwaway
+ `postgres:18-alpine` container (the VM has no host-installed Postgres client —
+ see the doc-drift correction below), keep the 5 newest, then apply:
+ ```bash
+ mkdir -p /home/ubuntu/backups
+ (
+ set -a; . backendServer/.env; set +a
+ docker run --rm \
+ -e DATABASE_URL \
+ -v /home/ubuntu/backups:/backups \
+ postgres:18-alpine \
+ sh -c 'set -o pipefail; pg_dump "$DATABASE_URL" | gzip > "/backups/pre-migrate-$(date +%Y%m%d-%H%M%S).sql.gz"'
+ )
+ ls -1t /home/ubuntu/backups/pre-migrate-*.sql.gz | tail -n +6 | xargs -r rm -f --
+ docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py migrate --noinput
+ ```
+ `postgres:18-alpine` is pinned deliberately — `pg_dump` can dump a server
+ *older* than itself but refuses one *newer*, so the newest client image stays
+ safe regardless of what Postgres version Neon runs, with no version-matching
+ exercise to redo on every Neon upgrade. This dump is belt-and-suspenders; Neon
+ PITR/branching is the real restore mechanism.
+
+ `collectstatic` and both frontend builds are **not** separate deploy steps
+ anymore — `collectstatic` runs at image build time (baked into
+ `/app/staticfiles_build/static`, then `COPY --from=`'d into the `nginx` image),
+ and both `pnpm build`s happened inside step 2 above. There is no host-side
+ `node_modules` or static directory left to manage.
+5. **Recreate every service from the images just built** — no `sudo`, no
+ `systemctl restart`:
+ ```bash
+ docker compose -f docker-compose.yml up -d
+ ```
+6. **Health assertion.** `migrate` and `broadcast-spa-build` are one-shot
+ (`restart: "no"`) and correctly exit 0, so they're excluded from the
+ "still running" check; every long-running service must be:
+ ```bash
+ docker compose -f docker-compose.yml ps
+ running_services=$(docker compose -f docker-compose.yml ps --status running --services)
+ for svc in redis backend celery celerybeat broadcast-worker scrape-worker nextjs nginx; do
+ printf '%s\n' "$running_services" | grep -qx "$svc" || { echo "$svc not running"; exit 1; }
+ done
+ ```
+7. **Post-deploy smoke test** (separate SSH step, so a deploy that "succeeded" but
+ serves garbage still fails CI): the three domains return 200, the broadcast
+ rate-limit path returns 403 (not 500 — regression-checks the old
+ Unix-socket-`REMOTE_ADDR` bug now that gunicorn is on TCP), `/auth/me` 401/403s
+ on no/garbage credentials (never 500), and a real `THE_COMMONS_API_KEY` against
+ `/events/create` gets past auth to a 400 body-validation error. See the
+ `deploy`/`Post-deploy smoke test` steps in `.github/workflows/ci.yml` for the
+ literal script.
### Manual fallback (CI down, or a hand hotfix)
```bash
-cd /home/ubuntu/thecommons && git pull
+cd /home/ubuntu/thecommons && git pull origin main
-# Backend (only the steps that apply)
-cd backendServer
-/snap/bin/uv sync # if pyproject.toml changed
-/snap/bin/uv run python manage.py migrate # if models changed
-/snap/bin/uv run python manage.py collectstatic --noinput # if static changed
-sudo systemctl restart gunicorn
-sudo systemctl restart celery celerybeat # if task code or deps changed
+docker compose -f docker-compose.yml build # rebuild whatever changed
+docker compose -f docker-compose.yml run --rm --no-deps migrate python manage.py migrate --noinput # if models changed
+docker compose -f docker-compose.yml up -d # recreate from the new images
+docker compose -f docker-compose.yml ps
+```
-# Frontend
-cd ../theCommonsWeb && pnpm install && pnpm run build && sudo systemctl restart nextjs
+To restart a single service without touching the rest (e.g. picked up a `.env`
+change with no code change):
-# Broadcast
-cd ../backendServer && sudo systemctl restart broadcast-worker
-cd ../broadcastWeb && pnpm install && pnpm run build # static — no service to restart
+```bash
+docker compose -f docker-compose.yml up -d --no-deps
+```
+
+If you only edited nginx's config (`deploy/nginx/thecommons.conf`), the change
+needs a rebuild — it's baked into the image at build time, not bind-mounted, in
+prod:
-# Scrape worker (if scraper/task code changed)
-cd ../backendServer && sudo systemctl restart scrape-worker
+```bash
+docker compose -f docker-compose.yml build nginx
+docker compose -f docker-compose.yml up -d --no-deps nginx
```
---
# Part 3 — Reference
+## Compose services
+
+| Service | What it is | Image / build target | Command | Notes |
+|---------|-----------|----------------------|---------|-------|
+| `redis` | Celery broker + results (DB 0) + read-endpoint cache (DB 1) | `redis:7-alpine` | `redis-server --requirepass … --appendonly yes` | Named volume `redis-data`; password from `REDIS_PASSWORD` in `backendServer/.env` |
+| `migrate` | One-shot: `manage.py migrate --noinput`, seeds `django_celery_beat` schedules | `backendServer/Dockerfile` target `app` | `python manage.py migrate --noinput` | `restart: "no"`; exits 0 by design — not a long-running service |
+| `backend` | Django / gunicorn | `backendServer/Dockerfile` target `app` | `gunicorn --bind 0.0.0.0:8000 --workers 3 backend.wsgi:application` | TCP `:8000`, internal only (`expose`, not `ports`) — no more unix socket |
+| `celery` | Default async worker (digest emails, etc. — everything not routed to `broadcast`/`scrape`) | same `app` image | `celery -A backend worker -n commons-default@%h -l info --concurrency=2` | `mem_limit: 1g` |
+| `celerybeat` | Scheduler | same `app` image | `celery -A backend beat -l info` | DatabaseScheduler; **exactly one** process — do not scale this service |
+| `broadcast-worker` | Playwright form-filler, drains the `broadcast` queue | `backendServer/Dockerfile` target `playwright` | `celery -A backend worker -Q broadcast -n commons-broadcast@%h -c 1 -l info` | `mem_limit: 2g`; **`-c 1` is mandatory**, not tuning — `recover_orphans()` assumes a single worker |
+| `scrape-worker` | Ingestion scraper (headless Chromium), drains the `scrape` queue | `backendServer/Dockerfile` target `playwright` | `celery -A backend worker -Q scrape -n commons-scrape@%h -c 1 -l info` | `mem_limit: 2g`; keeps Chromium memory off the default worker |
+| `nextjs` | theCommonsWeb (Next.js) | `Dockerfile.frontend` target `commons-runtime` | `node server.js` | Port 3000, internal only |
+| `broadcast-spa-build` | Build-only: produces the compiled broadcastWeb SPA for `nginx` to `COPY --from=` | `Dockerfile.frontend` target `broadcast-build` | `true` (no-op) | `restart: "no"`; never actually runs as a service — exists so `docker compose up` doesn't crash-loop on a stray start |
+| `nginx` | Single ingress for all three subdomains | `deploy/nginx/Dockerfile` | (stock `nginx:1.27-alpine` entrypoint) | **Only** service publishing host ports (`80:80`, `443:443`); bakes in both `backend`'s `collectstatic` output and `broadcast-spa-build`'s `dist/` via Buildx named contexts |
+
+All images build for **arm64**, matching the VM
+(`docs/adr/0001-containerization.md` §Base images). Every long-running service's
+`CMD`/`command` execs the real binary directly (no wrapper shell) so the
+container's PID 1 receives `SIGTERM` on `docker stop`/`down` and shuts down
+cleanly — the one piece of the old systemd `ExecStart=.venv/bin/celery …`
+convention worth keeping, for an unrelated reason to why it existed originally
+(see §Historical incident).
+
+The three Celery **workers** pass `-n commons-{default,scrape,broadcast}@%h`.
+Without it they'd all default to `celery@`, and duplicate nodenames make
+`celery -A backend inspect|control` ambiguous (`DuplicateNodenameWarning`, and a
+`revoke` can misroute). Verify all three are distinct:
+
+```bash
+docker compose -f docker-compose.yml exec backend celery -A backend inspect ping
+# expect three distinct nodes: commons-default@, commons-scrape@, commons-broadcast@
+```
+
+**Logs and status — the container replacements for `journalctl`/`systemctl`:**
+
+```bash
+docker compose -f docker-compose.yml ps # replaces `systemctl status `
+docker compose -f docker-compose.yml logs # replaces `journalctl -u -n 50`
+docker compose -f docker-compose.yml logs -f # add -f to follow, same as journalctl -f
+docker compose -f docker-compose.yml restart # replaces `systemctl restart `
+```
+
+Logging is plain `json-file` with rotation (`max-size: 10m`, `max-file: 3` — the
+`x-logging` anchor at the top of `docker-compose.yml`), since there's no
+`journald` inside a container.
+
## Health check
-One command prints a scannable report of the whole box — RAM/disk, every systemd
-unit, Redis, Postgres, the Celery worker, and whether the beat schedule is firing:
+Unchanged interface, container-aware internals. One command still prints a
+scannable report of the whole box:
```bash
cd /home/ubuntu/thecommons
-UV_BIN=/snap/bin/uv bash deploy/healthcheck.sh
-UV_BIN=/snap/bin/uv bash deploy/healthcheck.sh --no-color | tee /tmp/health.log
-```
-
-It checks (✓/!/✗): RAM/disk vs thresholds; `systemctl is-active` for `redis-server`,
-`celery`, `celerybeat`, `gunicorn`, `nextjs`, `broadcast-worker`, `scrape-worker`; leftover OS-cron
-lines; and via `manage.py healthcheck` — Postgres `SELECT 1`, Redis broker ping
-(DB 0), Django cache round-trip (DB 1), a Celery worker `control.ping`, and each
-seeded `PeriodicTask` (enabled + last-run freshness: daily within ~25h, weekly
-within ~8d). A stale or never-run schedule is a **`FAIL`**, not a warning — it was
-a warning until the 2026-07-21 outage, which is precisely why a month-dead beat
-still exited 0. A seeded task with no window configured in `DEFAULT_STALENESS_HOURS`
-reports `WARN` rather than silently passing. It exits non-zero on any critical
-failure. Tunables:
-`RAM_WARN`/`RAM_FAIL` (80/95), `DISK_WARN`/`DISK_FAIL` (80/95), `CELERY_TIMEOUT`
-(1.0s), `UV_BIN` (default `uv`; the VM has it at `/snap/bin/uv`). The Django command
-also runs standalone: `/snap/bin/uv run python manage.py healthcheck [--json]`.
-
-### Scheduled health check (systemd timer)
-
-`deploy/healthcheck.sh` catches the failure modes that let the 2026-07-21 scheduler
-outage run silent for five weeks — but only if something actually runs it. A systemd
-timer does that hourly, independent of Celery beat (beat is one of the things being
-checked, so it can't be the thing doing the checking).
+bash deploy/healthcheck.sh
+bash deploy/healthcheck.sh --no-color | tee /tmp/health.log
+```
+
+It checks (✓/!/✗): RAM/disk vs. thresholds; `docker compose ps`/`docker inspect`
+liveness (running vs. restarting vs. exited, health status, restart count) for
+every long-running service in the table above (`migrate` and
+`broadcast-spa-build` are deliberately excluded — both are one-shot and exiting 0
+is correct, not a failure); leftover legacy OS-cron lines; and, via
+`docker compose exec -T backend python manage.py healthcheck`, Postgres
+`SELECT 1`, Redis broker ping (DB 0), Django cache round-trip (DB 1), a Celery
+worker `control.ping`, and each seeded `PeriodicTask`'s freshness (daily within
+~25h, weekly within ~8d). A stale or never-run beat schedule is a **`FAIL`**, not a
+`WARN` — that's precisely the bug class that let the 2026-07-21 outage run silent
+for weeks (§Historical incident below). Exits non-zero on any critical failure.
+
+Tunables (env vars): `RAM_WARN`/`RAM_FAIL` (80/95), `DISK_WARN`/`DISK_FAIL`
+(80/95), `CELERY_TIMEOUT` (1.0s), `RESTART_WARN` (3 — a container that's
+auto-restarted this many times trips a `WARN` as a possible crash-loop),
+`COMPOSE_FILE` (default `docker-compose.yml` — override only for local testing,
+never on the VM). The Django command also runs standalone inside the container:
-1. Copy the unit files and enable the **timer**, not the service:
+```bash
+docker compose -f docker-compose.yml exec backend python manage.py healthcheck [--json]
+```
+
+### Scheduled health check (host systemd timer)
+Deliberately still a **host** systemd timer, not a Compose `healthcheck:` block or
+a Celery beat task — both of those go silent in exactly the "everything is down"
+case this exists to catch (a container health check only watches its own
+container; beat watching itself is circular). A host-level timer fires even if
+every container in `docker-compose.yml`, including `nginx`, is down.
+
+1. Copy the unit files and enable the **timer**, not the service:
```bash
sudo cp deploy/healthcheck.service deploy/healthcheck.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now healthcheck.timer
```
-
-2. Verify it's scheduled:
-
- ```bash
- systemctl list-timers healthcheck.timer
- ```
-
- Expect a `NEXT`/`LEFT` time within the hour, and `LAST`/`PASSED` populated after
- the first run.
-
+ `User=ubuntu` in `healthcheck.service` needs `ubuntu` in the `docker` group
+ (Part 1 §2) — the timer shells out to `docker compose ps`/`exec`, not `sudo`.
+2. Verify it's scheduled: `systemctl list-timers healthcheck.timer` — expect a
+ `NEXT`/`LEFT` time within the hour, `LAST`/`PASSED` populated after the first run.
3. Check the most recent run:
-
```bash
systemctl status healthcheck.service
journalctl -u healthcheck.service -n 50 --no-pager
```
-
- A failing run shows `Active: failed` plus the `deploy/healthcheck.sh` report in
- the journal — look for `✗`/`FAIL` lines, most importantly any `beat:`
- entry (stale or never-run schedule, or missing from the schedule entirely).
-
-4. To see failed runs across all timers: `systemctl --failed`.
-
-> **Expect an immediate FAIL on first install.** `scrape-sources-daily` has never run
-> on prod (`last_run_at` is NULL), so the never-run rule fires until beat completes
-> one. That is the outage this check exists to surface — don't silence it, fix the
-> schedule.
-
-> **No push notification is wired up yet.** There is no `OnFailure=` target,
-> email, or Slack hook — `systemctl --failed` and the journal are the only read
-> paths. Check them as part of routine ops until that's built.
-
-## Services
-
-| Service | What it is | File | Notes |
-|---------|-----------|------|-------|
-| `gunicorn` | Django backend | `/etc/systemd/system/gunicorn.service` | `unix:/run/gunicorn/gunicorn.sock`, 3 sync workers; `RuntimeDirectory=gunicorn` creates `/run/gunicorn/` |
-| `nextjs` | Next.js frontend | `/etc/systemd/system/nextjs.service` | Port 3000, `npm run start` from `theCommonsWeb/` |
-| `redis-server` | Celery broker + cache | `/etc/redis/redis.conf` | localhost-bound, password-protected, 512 MB allkeys-lru |
-| `celery` | Async task worker | `deploy/celery.service` | `.venv/bin/celery -A backend worker`, concurrency 2, drains Redis DB 0 |
-| `celerybeat` | Scheduler | `deploy/celerybeat.service` | `.venv/bin/celery -A backend beat`, DatabaseScheduler; **exactly one** process |
-| `broadcast-worker` | Playwright form-filler | `deploy/broadcast-worker.service` | `.venv/bin/celery -A backend worker -Q broadcast -c 1`, MemoryMax 2G, drains the dedicated `broadcast` queue only |
-| `scrape-worker` | Ingestion scraper (Playwright) | `deploy/scrape-worker.service` | `.venv/bin/celery -A backend worker -Q scrape -c 1`, MemoryMax 2G, drains the dedicated `scrape` queue only |
-
-All four `ExecStart=` lines exec `/home/ubuntu/thecommons/backendServer/.venv/bin/celery`
-directly rather than `uv run celery` — see §4 for why (the 2026-07-21 outage) and the
-one-shot vs long-lived `uv` rule in the Facts table above.
-
-The three **workers** also pass `-n commons-{default,scrape,broadcast}@%%h`. Without it
-they all default to `celery@`, and duplicate nodenames make
-`celery -A backend inspect|control` ambiguous — it returns several replies under one
-name (`DuplicateNodenameWarning`) and can misroute a revoke. The doubled `%%` is
-required: systemd expands a bare `%h` to the *user home directory*, so `%%h` is what
-passes a literal `%h` through for celery to expand to the hostname. Verify with:
-
-```bash
-cd /home/ubuntu/thecommons/backendServer && .venv/bin/celery -A backend inspect ping
-# expect three distinct nodes: commons-default@, commons-scrape@, commons-broadcast@
-```
-
-```bash
-sudo systemctl status
-sudo systemctl restart
-sudo journalctl -u -n 50 # add -f to follow
-```
+ (This one still goes through `journalctl` — the timer/service pair itself is a
+ host systemd unit, not a container; only the app stack it inspects moved.)
+4. `systemctl --failed` shows failed runs across all timers.
+
+> **Expect an immediate FAIL on first install** if `scrape-sources-daily` has
+> never run (`last_run_at` is NULL) — the never-run rule fires until beat
+> completes one cycle. That's the outage this check exists to surface; don't
+> silence it, let beat run.
+
+> **No push notification is wired up yet** — `systemctl --failed` and the journal
+> are the only read paths until an `OnFailure=` target/email/Slack hook is built.
+
+## Local development (`docker-compose.override.yml`)
+
+Plain `docker compose up --build` (no `-f`) auto-loads
+`docker-compose.override.yml` alongside `docker-compose.yml` — Compose's default
+two-file merge. It swaps the baked Cloudflare-cert `nginx` config for an
+HTTP-only one using `*.localhost` hostnames (no `/etc/hosts` edits needed, per RFC
+6761), remaps every bind-mounted host path from `/home/ubuntu/...` to
+`./.local-dev/...`, and points `REDIS_URL`/`REDIS_CACHE_URL` at the `redis`
+Compose service instead of `localhost` (same cutover fix as Part 1 §6, just
+pre-applied for dev). See that file's header comment for the full explanation of
+why it exists as a separate file rather than a second tracked nginx config.
+
+> **Gotcha, discovered during verification:** Compose does **not** auto-recreate a
+> container when the *content* of an inline `configs:` entry changes (the local
+> `nginx_dev_conf` block in `docker-compose.override.yml` is defined this way). If
+> you edit that inline config and run a plain `docker compose up -d`, `nginx`
+> silently keeps running with the old config. Force it:
+> ```bash
+> docker compose up -d --force-recreate nginx
+> ```
## Environment variables
@@ -552,7 +723,7 @@ sudo journalctl -u -n 50 # add -f to follow
```
DATABASE_URL= # Neon Postgres connection string
DJANGO_SECRET_KEY=
-DJANGO_ENV=prod # selects settings/prod.py (omit locally → dev)
+DJANGO_ENV=prod # selects settings/prod.py — also set redundantly via compose's `environment:` on every backend/Celery service
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,api.thecommons.town
CORS_EXTRA_ORIGINS=https://thecommons.town,https://broadcast.thecommons.town
@@ -561,7 +732,7 @@ GEMINI_API_KEY=
CRON_SECRET=
THE_COMMONS_API_KEY=
SAFETY_SCORE_THRESHOLD=0.3 # optional
-INGEST_SHARD_COUNT=3 # optional — see §3
+INGEST_SHARD_COUNT=3 # optional, deliberate prod setting — see note below
INGEST_SCRAPER_HEADLESS=true # optional — default true
INGEST_SCRAPER_TIMEOUT_MS=30000 # optional — default 30000
INGEST_SCRAPER_USER_AGENT=Mozilla/5.0 (compatible; TheCommons/1.0) # optional — default shown
@@ -571,8 +742,9 @@ BETTER_AUTH_AUDIENCE=
BREVO_API_KEY=
DIGEST_FROM_EMAIL=digest@thecommons.town
SITE_URL=https://thecommons.town
-REDIS_URL=redis://:@127.0.0.1:6379/0 # Celery broker + results (DB 0)
-REDIS_CACHE_URL=redis://:@127.0.0.1:6379/1 # read-endpoint cache (DB 1)
+REDIS_PASSWORD= # container-only knob — must match the password embedded below
+REDIS_URL=redis://:@redis:6379/0 # Celery broker + results (DB 0) — `redis`, NOT 127.0.0.1 (Part 1 §6)
+REDIS_CACHE_URL=redis://:@redis:6379/1 # read-endpoint cache (DB 1)
# Broadcast (see backendServer/.env.example for the full annotated block)
BROADCAST_HEADLESS=true
BROADCAST_DRY_RUN_DEFAULT=false
@@ -580,9 +752,20 @@ BROADCAST_MAX_CONCURRENCY=1
BROADCAST_SCREENSHOT_DIR=/home/ubuntu/broadcast/screenshots
BROADCAST_DOWNLOAD_DIR=/home/ubuntu/broadcast/downloads
BROADCAST_TIMEOUT_MS=30000
-MEDIA_ROOT=/home/ubuntu/broadcast/media # client-uploaded event images (T7) — outside the repo checkout so `git pull` never touches it
+MEDIA_ROOT=/home/ubuntu/broadcast/media # client-uploaded event images — bind-mounted into `backend`/`nginx` at this identical path
```
+**`INGEST_SHARD_COUNT=3` is a deliberate prod setting — keep it at 3.**
+`_resolve_env_shard` (`backendServer/ingestion/tasks.py:22-39`) polls only the
+sources where `id % INGEST_SHARD_COUNT == day_of_year % INGEST_SHARD_COUNT`, so
+with a shard count of 3 any given source is actually polled roughly every **72
+hours**, not the 24h its own `poll_interval_hours` implies — sharding is disabled
+(all sources polled daily) only when the var is unset, fails to parse as an int,
+or is `<= 1`. Two consequences: a source that fails transiently gets 1/3 as many
+chances to recover before the next poll compared to unsharded daily polling, and
+monitoring thresholds must be shard-aware — a source that looks "3 days stale" is
+expected and healthy under this setting, not a symptom of an outage.
+
### `theCommonsWeb/.env.local`
```
@@ -595,6 +778,11 @@ NEXT_PUBLIC_BETTER_AUTH_URL=https://auth.thecommons.town
BETTER_AUTH_COOKIE_DOMAIN=.thecommons.town # enables cross-subdomain sessions (SameSite=None; Secure)
```
+Loaded into the running `nextjs` container via `env_file:`, **and** its
+`NEXT_PUBLIC_*`/`DATABASE_URL`/`BETTER_AUTH_*` values get baked into the compiled
+JS at image build time via the `NEXTJS_BUILD_*`-prefixed build args (Part 2 step
+2) — two different mechanisms consuming the same file, at two different times.
+
### `broadcastWeb/.env`
```
@@ -603,47 +791,56 @@ VITE_BROADCAST_EXTENSION_ID= # Chrome extension ID for extension autof
VITE_BETTER_AUTH_URL=https://auth.thecommons.town
```
-## nginx
+`broadcastWeb` has no running container of its own — this file only matters at
+**build** time (`broadcast-spa-build`'s `VITE_*` build args, Part 2 step 2). Vite
+inlines these into the compiled bundle; there is no runtime env to load them into.
-- Config: `/etc/nginx/sites-available/thecommons` (symlinked into `sites-enabled/`) —
- one file, multiple `server` blocks.
-- Routes: `thecommons.town` → `localhost:3000` (Next.js); `api.thecommons.town` →
- `unix:/run/gunicorn/gunicorn.sock` (Django); `www` → 301 to apex; HTTP → 301 to
- HTTPS; `api.thecommons.town/static/` → `backendServer/staticfiles/`;
- `api.thecommons.town/media/` → `MEDIA_ROOT` (client-uploaded broadcast event
- images, T7 — served by nginx directly, **never** by Django/gunicorn in prod);
- `broadcast.thecommons.town` → the block from `deploy/nginx-broadcast.conf.snippet`.
-
- Add a sibling alias to the existing `/static/` block in the `api.thecommons.town`
- server block:
-
- ```nginx
- location /media/ {
- alias /home/ubuntu/broadcast/media/;
- }
- ```
+## nginx
- `MEDIA_ROOT` (`/home/ubuntu/broadcast/media` by default — see env vars below)
- must exist and be writable by the gunicorn user (`ubuntu`) before the first
- upload: `mkdir -p /home/ubuntu/broadcast/media`. It lives outside the repo
- checkout alongside `BROADCAST_SCREENSHOT_DIR`/`BROADCAST_DOWNLOAD_DIR` so a
- `git pull` during deploy never touches it, and it survives deploys for the
- same reason. **Uploaded images are kept indefinitely — no pruning job exists.**
- `MEDIA_ROOT` grows without bound; at roughly 1–3 MB per event against the VPS
- block volume this is negligible for the foreseeable future, but keep it in
- mind at ops time (a prune command can be added later without a migration).
+- Config: [`deploy/nginx/thecommons.conf`](deploy/nginx/thecommons.conf), baked
+ into the `nginx` image at `/etc/nginx/conf.d/thecommons.conf` by
+ [`deploy/nginx/Dockerfile`](deploy/nginx/Dockerfile) — **not** a bind mount in
+ prod, so an edit requires a rebuild (`docker compose -f docker-compose.yml build
+ nginx && docker compose -f docker-compose.yml up -d --no-deps nginx`), not
+ `nginx -t && systemctl reload nginx`. One file, multiple `server` blocks — the
+ repo's long-standing convention, preserved rather than restructured.
+- Routes: `thecommons.town` → `proxy_pass http://backend:8000` was the old
+ wording; today it's `thecommons.town` → `http://nextjs:3000` (Next.js);
+ `api.thecommons.town` → `http://backend:8000` (Django/gunicorn, **TCP now, not
+ a unix socket** — see the correction below); `www` → 301 to apex; HTTP → 301 to
+ HTTPS; `api.thecommons.town/static/` → the baked `collectstatic` output at
+ `/usr/share/nginx/html/static` (`COPY --from=` the `backend` image's
+ `/app/staticfiles_build/static`, matching `STATIC_ROOT` in
+ `backendServer/backend/settings/base.py:91`); `api.thecommons.town/media/` →
+ `/var/www/media` (bind-mounted `MEDIA_ROOT`, read-only from nginx's side —
+ **never** served by Django/gunicorn in prod); `broadcast.thecommons.town` → the
+ baked SPA at `/usr/share/nginx/html/broadcast` (`COPY --from=`
+ `broadcast-spa-build`'s `/app/dist`).
+- All `proxy_pass` targets resolve through a `resolver 127.0.0.11 …` directive
+ (Docker's embedded DNS) via an nginx variable rather than a literal hostname —
+ a literal `proxy_pass http://nextjs:3000` makes nginx resolve once at boot and
+ **refuse to start** if that service isn't up yet; resolving through a variable
+ defers the lookup to request time, so one crashed upstream degrades to a 502 on
+ just that hostname instead of taking the whole ingress down.
+- `MEDIA_ROOT` (`/home/ubuntu/broadcast/media`) must exist and be uid-1000-writable
+ before the first upload — covered in Part 1 §3. **Uploaded images are kept
+ indefinitely — no pruning job exists**, at roughly 1–3 MB/event against the VPS
+ block volume; negligible for now, but a prune command could be added later
+ without a migration.
```bash
-sudo nginx -t && sudo systemctl reload nginx
+docker compose -f docker-compose.yml build nginx
+docker compose -f docker-compose.yml up -d --no-deps nginx
```
## Firewall
-Two layers must allow 80/443:
+Unchanged by containerization — the container `nginx` binds the host's 80/443
+exactly like the systemd one did, so both layers still apply:
1. **Oracle VCN Security List** (OCI console) — ingress on 22, 80, 443.
-2. **iptables on the VM** — Oracle Ubuntu images ship a catch-all `REJECT` in INPUT.
- The 80/443 ACCEPT rules must sit **above** it:
+2. **iptables on the VM** — Oracle Ubuntu images ship a catch-all `REJECT` in
+ INPUT. The 80/443 ACCEPT rules must sit **above** it:
```bash
sudo iptables -L INPUT -n --line-numbers
sudo iptables -I INPUT 5 -p tcp --dport 443 -m state --state NEW -j ACCEPT
@@ -656,18 +853,56 @@ Two layers must allow 80/443:
| Symptom | Likely cause | Check |
|---------|-------------|-------|
| `curl` to IP / subdomain returns nothing | iptables REJECT before ACCEPT | `sudo iptables -L INPUT -n --line-numbers` |
-| nginx 502 Bad Gateway | gunicorn or nextjs down | `sudo systemctl status gunicorn nextjs` |
-| broadcast subdomain → Cloudflare 526 | origin cert doesn't cover `*.thecommons.town` | reissue cert (Part 1 §5.1) |
-| Django `DisallowedHost` | host missing from `ALLOWED_HOSTS` | `DJANGO_ALLOWED_HOSTS` in `.env` |
-| 400 on `/events/` from browser | `NEXT_PUBLIC_API_BASE_URL` wrong or stale build | `.env.local`, then `pnpm run build` |
-| Django admin has no CSS | `collectstatic` not run / `/static/` alias wrong | `manage.py collectstatic --noinput` |
-| Celery worker won't start / no broker | `REDIS_URL` missing/wrong password, or Redis down | `redis-cli -a '' PING`; `journalctl -u celery -n 50` |
-| Scheduled job ran twice | leftover OS cron alongside beat | `crontab -l` (Part 1 §9) |
+| nginx 502 Bad Gateway | `backend` or `nextjs` container down/unhealthy | `docker compose -f docker-compose.yml ps`; `docker compose -f docker-compose.yml logs backend nextjs` |
+| broadcast subdomain → Cloudflare 526 | origin cert doesn't cover `*.thecommons.town`, or the cert bind mount is missing/wrong path | reissue cert (Part 1 §4); confirm `nginx`'s `volumes:` in `docker-compose.yml` |
+| Django `DisallowedHost` | `DJANGO_ALLOWED_HOSTS` missing from `backendServer/.env` — `prod.py:16` hard-crashes without it | `docker compose -f docker-compose.yml logs backend`; fix `.env`, `docker compose -f docker-compose.yml up -d --no-deps backend` |
+| 400 on `/events/` from browser | `NEXT_PUBLIC_API_BASE_URL` wrong at **build** time — a runtime `.env.local` edit alone does nothing, since Next inlined the old value already | fix `theCommonsWeb/.env.local`, then `docker compose -f docker-compose.yml build nextjs && docker compose -f docker-compose.yml up -d --no-deps nextjs` |
+| Django admin has no CSS | `collectstatic` output stale or missing in the `backend` image, or nginx's `/static/` alias path wrong | rebuild `backend` and `nginx`: `docker compose -f docker-compose.yml build backend nginx && docker compose -f docker-compose.yml up -d --no-deps backend nginx` |
+| Celery worker won't start / no broker | `REDIS_URL` still points at `127.0.0.1`/`localhost` instead of `redis`, wrong password, or the `redis` container is down | `docker compose -f docker-compose.yml logs redis celery`; re-check Part 1 §6 |
+| Scheduled job ran twice | leftover OS cron alongside beat | `crontab -l` (should be empty of `ingest_events`/`send_weekly_digest` lines — Part 1 §9) |
+| Edited `docker-compose.override.yml`'s inline nginx config locally, but the container is still serving the old one | Compose doesn't auto-recreate on inline `configs:` content changes | `docker compose up -d --force-recreate nginx` |
+
+## Historical incident: 2026-07-21 scheduler outage (containers close this gap)
+
+Kept for context, not as a template to reproduce. All four Celery systemd units
+used to exec `/snap/bin/uv run celery …`. Snap-packaged `uv run` spawned its child
+process inside a transient `snap.astral-uv.uv-*.scope` under `user@1001.service` —
+the *user* session manager, not the unit's own cgroup. With linger off,
+`systemd-logind` tore that user slice down the moment the deploying SSH session
+ended, and every `uv run`-based unit died within a minute of each deploy,
+silently, with `status=0/SUCCESS` — a clean exit that `Restart=on-failure`
+correctly declined to restart. That took the entire async stack down for 8 days
+starting 2026-07-21 while `gunicorn` and `nextjs` (which never went through snap)
+stayed up the whole time. Full forensics:
+[`docs/prod-incident-2026-07-21-scheduler-outage.md`](docs/prod-incident-2026-07-21-scheduler-outage.md).
+
+The mitigation at the time was two-layered: exec the venv binary directly
+(`.venv/bin/celery`, removing the snap-scope mechanism entirely) plus
+`Restart=always` + `loginctl enable-linger ubuntu` as defense-in-depth.
+
+**Containers remove this failure mode by construction** — there is no snap, no
+`logind`, no per-user systemd slice inside a container's PID namespace for a lost
+SSH session to tear down. **Do not cargo-cult the `uv run` avoidance, the linger
+setting, or `Restart=always`-as-a-workaround forward into the Compose/Dockerfile
+setup** — none of them are solving a live problem there. The one piece that *is*
+still worth keeping, for an unrelated reason, is exec'ing binaries directly rather
+than through a wrapper shell (`CMD ["gunicorn", …]` / `command: ["celery", …]`,
+not a shell script) — so the container's PID 1 is the real process and receives
+`SIGTERM` directly on `docker stop`/`down` for a clean shutdown. Same shape,
+different justification.
+
+What still matters from this incident either way: a stale/dead beat schedule is a
+`FAIL` in `deploy/healthcheck.sh`, not a `WARN` (§Health check above) — that
+distinction is what would have caught this outage in hours instead of weeks, and
+it has nothing to do with systemd vs. containers.
## Deep-dive references
-- `docs/redis-celery-handoff.md` — Redis/Celery internals, task conventions, beat schedules
-- `docs/broadcast.md` — broadcast subsystem: routing, adapters, worker, recipe layer, extension, SPA wiring
-- `docs/dev-db-isolation.md` — Neon dev branch setup for local development
-- `docs/ingestion-pipeline.md` — scrape → stage → publish flow
-- `docs/runbook-auth-cutover.md` — Auth-origin cutover (auth.thecommons.town subdomain, .thecommons.town cookie domain, forced re-login)
+- [`docs/adr/0001-containerization.md`](docs/adr/0001-containerization.md) — the
+ four containerization decisions (nginx-in-a-container, Redis-in-a-container,
+ Postgres staying external, baked-vs-volumed artifacts) and their rationale
+- [`docs/redis-celery-handoff.md`](docs/redis-celery-handoff.md) — Redis/Celery internals, task conventions, beat schedules
+- [`docs/broadcast.md`](docs/broadcast.md) — broadcast subsystem: routing, adapters, worker, recipe layer, extension, SPA wiring
+- [`docs/dev-db-isolation.md`](docs/dev-db-isolation.md) — Neon dev branch setup for local development
+- [`docs/ingestion-pipeline.md`](docs/ingestion-pipeline.md) — scrape → stage → publish flow
+- [`docs/runbook-auth-cutover.md`](docs/runbook-auth-cutover.md) — Auth-origin cutover (auth.thecommons.town subdomain, .thecommons.town cookie domain, forced re-login)
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
new file mode 100644
index 0000000..64734b4
--- /dev/null
+++ b/Dockerfile.frontend
@@ -0,0 +1,187 @@
+# syntax=docker/dockerfile:1
+#
+# Shared frontend Dockerfile for The Commons — builds BOTH frontend apps from
+# one file, each in its own isolated stage-tree:
+#
+# - `commons-runtime` — theCommonsWeb (Next.js 16), a real long-lived Node
+# process. Runs `node server.js` on port 3000.
+# - `broadcast-build` — broadcastWeb (Vite 7 SPA), BUILD-ONLY. Produces a
+# static bundle at /app/dist and stops there — a
+# separate nginx image COPYs --from this stage and
+# serves the files; this stage itself never runs a
+# server and must not be used as a final image.
+#
+# theCommonsWeb/ and broadcastWeb/ are two SEPARATE pnpm workspaces (own
+# pnpm-workspace.yaml + own pnpm-lock.yaml, not one monorepo), so each gets
+# its own independent `pnpm install --frozen-lockfile` — nothing is shared
+# or hoisted between them.
+#
+# Build context is the REPO ROOT (both apps' source lives under it):
+# docker build -f Dockerfile.frontend --target commons-runtime .
+# docker build -f Dockerfile.frontend --target broadcast-build .
+#
+# Target platform: Oracle Ubuntu 24.04 on ARM64 (prod VM); dev is Apple
+# Silicon. node:22-slim is multi-arch, so no --platform pin is needed.
+
+########################################################################
+# Stage: pnpm-base
+#
+# Shared base only for pinning the Node/pnpm toolchain — NOT for sharing
+# installed dependencies (the two apps' installs stay fully separate below).
+# pnpm 11 requires Node >=22.13; node:22-slim tracks current 22.x, so it
+# satisfies that floor.
+########################################################################
+FROM node:22-slim AS pnpm-base
+
+# Pin the exact pnpm version CI uses (.github/workflows/ci.yml pins
+# pnpm/action-setup@v4 to 11.1.1) via corepack, which ships with Node 22 —
+# no separate npm install of pnpm itself (that would violate the
+# pnpm-only/no-npm-install rule for a tool that manages pnpm).
+RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
+
+########################################################################
+# Stage: commons-deps
+#
+# Installs theCommonsWeb's locked dependencies only. Split from the build
+# stage so this (slow) layer caches across source-only changes.
+########################################################################
+FROM pnpm-base AS commons-deps
+
+WORKDIR /app
+
+# Manifests first for layer caching — copying full source here would bust
+# the cache on every source edit even when dependencies didn't change.
+COPY theCommonsWeb/package.json theCommonsWeb/pnpm-lock.yaml theCommonsWeb/pnpm-workspace.yaml ./
+# --frozen-lockfile: fail rather than silently re-resolve, matching CI.
+# pnpm-workspace.yaml's allowBuilds (esbuild, sharp, better-sqlite3,
+# @prisma/client, unrs-resolver) still applies here since it's copied in
+# above — native postinstall scripts keep working.
+RUN pnpm install --frozen-lockfile
+
+########################################################################
+# Stage: commons-build
+#
+# Builds the Next.js app to its standalone output. Build-time env vars
+# mirror the placeholder set CI uses (.github/workflows/ci.yml
+# frontend-commons "Type-check (build)" step) — `next build` prerenders
+# routes that import src/lib/db.ts (throws without DATABASE_URL) and
+# Better Auth (parses *_BETTER_AUTH_URL as a URL). These are placeholders
+# baked only into build-time tracing, never real credentials, and are
+# discarded — the running container gets its real config from
+# theCommonsWeb/.env.local via compose's env_file at container start, not
+# from anything in this image.
+########################################################################
+FROM commons-deps AS commons-build
+
+WORKDIR /app
+COPY theCommonsWeb/ ./
+
+ARG DATABASE_URL=postgres://build:build@localhost:5432/build
+ARG BETTER_AUTH_SECRET=build-only-placeholder-secret
+ARG BETTER_AUTH_URL=http://localhost:3000
+ARG NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
+ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
+ARG NEXT_PUBLIC_THE_COMMONS_API_KEY=build-placeholder
+ENV DATABASE_URL=$DATABASE_URL \
+ BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \
+ BETTER_AUTH_URL=$BETTER_AUTH_URL \
+ NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \
+ NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
+ NEXT_PUBLIC_THE_COMMONS_API_KEY=$NEXT_PUBLIC_THE_COMMONS_API_KEY
+
+RUN pnpm run build
+
+########################################################################
+# Stage: commons-runtime (contract name — final Next.js image)
+#
+# Copies only the standalone trace, not node_modules. Next's documented
+# standalone layout (see Next.js docs, "output: standalone"):
+# .next/standalone/ -> server.js + a pruned node_modules, becomes /app
+# .next/static/ -> NOT included in standalone automatically; must be
+# copied to /app/.next/static by hand, or the site
+# serves with no CSS/JS chunks (the classic failure
+# mode this stage exists to avoid).
+# public/ -> also not included automatically; copied to /app/public.
+########################################################################
+FROM node:22-slim AS commons-runtime
+
+WORKDIR /app
+
+# Non-root runtime user (contract requirement). node:22-slim already ships a
+# uid/gid 1000 `node` user — reused here rather than creating a second one
+# (a fresh `groupadd --gid 1000` collides with it and fails the build).
+#
+# Order matters: standalone first (least likely to change), then the two
+# directories standalone mode deliberately omits.
+COPY --from=commons-build --chown=node:node /app/.next/standalone ./
+COPY --from=commons-build --chown=node:node /app/.next/static ./.next/static
+COPY --from=commons-build --chown=node:node /app/public ./public
+
+USER node
+
+ENV NODE_ENV=production \
+ PORT=3000 \
+ HOSTNAME=0.0.0.0
+
+EXPOSE 3000
+
+# Exec-form CMD so node runs as PID 1 and receives signals directly.
+# server.js is the standalone entrypoint next build emits.
+CMD ["node", "server.js"]
+
+########################################################################
+# Stage: broadcast-deps
+#
+# Installs broadcastWeb's locked dependencies only — entirely separate
+# pnpm workspace/lockfile from theCommonsWeb, so this does not reuse or
+# extend commons-deps above.
+########################################################################
+FROM pnpm-base AS broadcast-deps
+
+WORKDIR /app
+
+COPY broadcastWeb/package.json broadcastWeb/pnpm-lock.yaml broadcastWeb/pnpm-workspace.yaml ./
+# broadcastWeb's allowBuilds only permits esbuild (see pnpm-workspace.yaml);
+# still copied in above so that allowlist is honored during install.
+RUN pnpm install --frozen-lockfile
+
+########################################################################
+# Stage: broadcast-build (contract name — BUILD-ONLY, no server)
+#
+# `pnpm run build` = `tsc -b && vite build`, output in dist/. Vite inlines
+# VITE_* build args at compile time (see broadcastWeb/.env.example) — these
+# become part of the compiled JS, not runtime env, so they must be passed
+# as build args here rather than left for container start.
+#
+# NOTE: prod CI (.github/workflows/ci.yml ~lines 274-281) greps the built
+# dist/assets/*.js for a thecommons.town origin as a guard against a
+# malformed VITE_BROADCAST_API_BASE_URL that "builds fine" but silently
+# misroutes every API call. That check lives in CI/deploy, not here — this
+# stage just needs correct build args passed in to produce a correct bundle.
+#
+# This stage is intentionally the last thing in this file: it has no CMD/
+# EXPOSE and must never be run as a standalone container — an nginx image
+# does `COPY --from=broadcast-build /app/dist ...` to consume it.
+########################################################################
+FROM broadcast-deps AS broadcast-build
+
+WORKDIR /app
+COPY broadcastWeb/ ./
+
+ARG VITE_BROADCAST_API_BASE_URL=http://localhost:8000
+ARG VITE_BETTER_AUTH_URL=http://localhost:3000
+ARG VITE_BROADCAST_EXTENSION_ID=
+ENV VITE_BROADCAST_API_BASE_URL=$VITE_BROADCAST_API_BASE_URL \
+ VITE_BETTER_AUTH_URL=$VITE_BETTER_AUTH_URL \
+ VITE_BROADCAST_EXTENSION_ID=$VITE_BROADCAST_EXTENSION_ID
+
+# tsc -b writes tsconfig.tsbuildinfo and vite build writes dist/ — both as
+# whatever user runs this stage. Non-root is only "where practical" per the
+# contract; this stage never ships as a runtime container (nothing to run
+# as root or otherwise), so it's left at the default build-time user, and
+# the only thing that matters is that /app/dist exists for the nginx image
+# to copy out.
+RUN pnpm run build
+
+# Deliberately no CMD/EXPOSE/USER-drop here — this stage is a build
+# artifact source, not a runnable image.
diff --git a/backendServer/.dockerignore b/backendServer/.dockerignore
new file mode 100644
index 0000000..3679801
--- /dev/null
+++ b/backendServer/.dockerignore
@@ -0,0 +1,39 @@
+# Build context is backendServer/ itself (docker build -f backendServer/Dockerfile backendServer).
+# Keep this list in sync with .gitignore where the two overlap.
+
+# Local virtualenv — the image builds its own venv via `uv sync`, never
+# reuses a host one (different OS/arch: dev is Apple Silicon, prod is
+# Oracle ARM64, but a stray macOS-built .venv would be architecture-wrong
+# either way).
+.venv/
+
+# Python bytecode / caches
+__pycache__/
+**/__pycache__/
+*.pyc
+*.pyo
+.mypy_cache/
+.ruff_cache/
+.pytest_cache/
+
+# Env files — config comes from compose's env_file at runtime, never baked
+# into the image. `.env*` also catches .env.local, .env.prod, etc.
+.env
+.env.*
+!.env.example
+
+# Generated/runtime data — collectstatic output is produced fresh at build
+# time; media and broadcast artifacts are runtime state that lives outside
+# the image (volumes in compose).
+staticfiles_build/
+media/
+broadcast_artifacts/
+
+# Editor/OS cruft
+.DS_Store
+.idea/
+.vscode/
+
+# VCS (not present under backendServer/, but harmless to exclude defensively)
+.git/
+.gitignore
diff --git a/backendServer/AGENTS.md b/backendServer/AGENTS.md
index f339514..7a48431 100644
--- a/backendServer/AGENTS.md
+++ b/backendServer/AGENTS.md
@@ -1,28 +1,45 @@
# backendServer — Agent Map
-Django 6 + DRF backend. Python 3.13, managed by `uv`. Four apps: `events` (public API + digests), `ingestion` (LLM pipeline), `broadcast` (event syndication), `backend` (config + auth bridge + Celery). Database is Postgres on Neon. Async on Redis + Celery; broadcast dispatch is on-demand Celery too, routed to its own single-concurrency `broadcast` queue (mirrors the `scrape` queue) rather than a polling worker. See [`../ARCHITECTURE.md`](../ARCHITECTURE.md) for cross-cutting detail.
+Django 6 + DRF backend. Python 3.13, managed by `uv`. Six apps: `accounts` (identity/auth-bridge), `events` (public event API), `newsletter` (subscriptions + digest engine), `ingestion` (LLM pipeline), `broadcast` (event syndication), `backend` (config + Celery, no domain logic). `devtools` is a seventh, dev/test-only app (see below). Database is Postgres on Neon. Async on Redis + Celery; broadcast dispatch is on-demand Celery too, routed to its own single-concurrency `broadcast` queue (mirrors the `scrape` queue) rather than a polling worker. See [`../ARCHITECTURE.md`](../ARCHITECTURE.md) for cross-cutting detail.
+
+**Isolation contract:** no app imports from `ingestion`/`broadcast`. `accounts`, `newsletter`, and `events` may read each other where the domain genuinely overlaps — e.g. `accounts.me` writes a `NewsletterSubscriber` row (email-preference sync), and `newsletter._build_recipients` reads `accounts.UserProfile` (tag-filtered digests). Both directions are deliberate. Enforced by `accounts/tests/test_isolation_fast.py` and `newsletter/tests/test_isolation_fast.py`.
## Directory Map
```
backendServer/
├── manage.py
-├── backend/ # Project config
+├── backend/ # Project config — no domain logic, include()-only urlconf
│ ├── settings/ # base / dev / prod / test
-│ ├── urls.py # Root URLconf (cron, publish, auth/me, businesses, admin)
+│ ├── urls.py # Root URLconf — include()s accounts/events/newsletter/ingestion/broadcast.urls
+│ │ # (+ admin, + DEBUG-gated devtools.urls); no `from .views import ...`
│ ├── celery.py # Celery app factory + autodiscover
│ ├── jwt_auth.py # BearerTokenAuthentication — Better Auth JWKS (TTL + stale-grace)
-│ ├── permissions.py # DRF auth/permission classes (JWT, API key)
+│ ├── permissions.py # DRF auth/permission classes (JWT, API key) — imports BetterAuthUser from accounts.models
│ └── test_runner.py # NeonAuthTestRunner — builds neon_auth schema for tests
-├── events/ # Public app
-│ ├── models.py # Event/Town/Tag/Category/UserProfile/BusinessProfile/Newsletter
-│ │ # + 5 BetterAuth* mirrors (managed=False)
-│ ├── views.py / serializers.py / urls.py
+├── accounts/ # Identity/auth-bridge app
+│ ├── models.py # 5 BetterAuth* mirrors (managed=False, neon_auth.*) + UserProfile/BusinessProfile
+│ │ # (both OneToOne→BetterAuthUser; a "business" is a kind of user profile)
+│ ├── views.py / serializers.py / urls.py # me (/auth/me), businesses, my_business, business_detail (/businesses...)
+│ ├── permissions.py # Isolation-contract docstring (no permission classes yet)
+│ └── tests/test_isolation_fast.py
+├── events/ # Public event app (slimmed)
+│ ├── models.py # Tag, Town, Category, Event
+│ ├── views.py / serializers.py / urls.py # get_all, get_one, get_towns, get_categories, create_event,
+│ │ # manage_staged_event, get_my_events, get_my_profile
│ ├── cache.py # Version-keyed Redis cache for hot read endpoints
│ ├── signals.py # Cache invalidation on Event/Town/Category writes
-│ ├── tasks.py # Celery: ping, send_one_digest, fan_out_weekly_digest, fan_out_monthly_digest
-│ ├── email_service.py # Brevo transactional email + digest builder
-│ └── management/commands/ # devserver, seed_dev, healthcheck, delete_user, send_*digest
+│ ├── tasks.py # Celery: ping (digest tasks moved to newsletter/tasks.py)
+│ ├── email_service.py # Generic Brevo transport (send_email) — used by non-digest commands
+│ └── management/commands/ # devserver, seed_dev, healthcheck, delete_user
+├── newsletter/ # Subscriptions + digest engine
+│ ├── models.py # NewsletterSubscriber
+│ ├── views.py / urls.py # subscribe (/newsletter/subscribe), newsletter_manage (/newsletter/manage)
+│ ├── email_service.py # _build_recipients, send_digest, digest_window, manage_url_for, send_newsletter_welcome
+│ ├── tasks.py # Celery: send_one_digest, fan_out_weekly_digest, fan_out_monthly_digest
+│ ├── templates/email/ # Digest + welcome email templates
+│ ├── management/commands/ # send_digest, send_weekly_digest, send_test_digest
+│ └── tests/test_isolation_fast.py
├── ingestion/ # Pipeline app
│ ├── models.py # EventSource, RawEvent, StagedEvent
│ ├── importers/ics_importer.py # ICS feed → RawEvent (shardable)
@@ -31,7 +48,8 @@ backendServer/
│ ├── safety_scorer.py # Gemini content-safety scoring
│ ├── services.py # publish_all_approved, auto_publish_safe_events
│ ├── tasks.py # Celery: run_ingestion_pipeline, publish_all_approved_task
-│ ├── views.py # cron_ingest, publish, admin doc pages
+│ ├── views.py / urls.py # cron_ingest, publish_approved_events, direct_submit, pipeline/admin doc pages
+│ │ # (own urlconf: admin/docs/*, api/cron/ingest, api/events/publish-approved, direct-submit)
│ └── management/commands/ # ingest_events, cleanup_old_events
├── broadcast/ # Event syndication (see ../docs/broadcast.md)
│ ├── models.py # BroadcastSubmission, BroadcastTarget, BroadcastAccess, AccessCode, AccessCodeUse
@@ -44,13 +62,25 @@ backendServer/
│ └── management/commands/ # run_broadcast_worker (--once debug helper), broadcast_dry_run, capture_broadcast_form,
│ # check_recipes, scaffold_adapter, set_broadcast_access,
│ # generate_access_code, list_access_codes, revoke_access_code
-├── templates/ # admin docs pages (docs/) + email digests (email/)
+├── devtools/ # Dev-only app (INSTALLED_APPS only under dev/test settings; DEBUG-gated in urls)
+│ ├── views/ # Package: playground.py, probe.py, monitor.py, _shared.py, __init__.py (re-exports)
+│ └── urls.py
+├── templates/ # admin docs pages (docs/) — email digest templates now live in newsletter/templates/email/
└── pyproject.toml / uv.lock
```
## API Endpoints
-Auth: `—` public · `user` Better Auth JWT · `key` `THE_COMMONS_API_KEY` · `tier≥N` broadcast tier (Bearer JWT or `X-Broadcast-Access-Code`, resolved by `broadcast/access.py`). `APPEND_SLASH=False` — slashes are exact. No global DRF config; auth/permissions are per-view.
+Auth: `—` public · `user` Better Auth JWT · `key` `THE_COMMONS_API_KEY` · `tier≥N` broadcast tier (Bearer JWT or `X-Broadcast-Access-Code`, resolved by `broadcast/access.py`). `APPEND_SLASH=False` — slashes are exact. No global DRF config; auth/permissions are per-view. `backend/urls.py` is `include()`-only — every route below is owned by the app it's grouped under.
+
+### accounts (`accounts/urls.py`)
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| GET/PATCH | `/auth/me` | user | Read / update own profile |
+| GET/POST | `/businesses` · `/businesses/me` · `/businesses/` | user | Business listing CRUD |
+
+### events (`events/urls.py`)
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
@@ -60,23 +90,44 @@ Auth: `—` public · `user` Better Auth JWT · `key` `THE_COMMONS_API_KEY` · `
| GET/PATCH/DELETE | `/events/staged/` | user | Manage own staged submission |
| GET/DELETE | `/events/` | user (delete) | Event detail / owner delete |
| POST | `/events/create` | user or key | Submit event → StagedEvent |
-| GET/PATCH | `/auth/me` | user | Read / update profile |
+
+### newsletter (`newsletter/urls.py`)
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
| POST | `/newsletter/subscribe` | — | Newsletter signup (welcome email + manage link) |
| GET/PATCH | `/newsletter/manage` | — (token) | View / change a subscription via `?token=` |
-| GET/POST | `/businesses` · `/businesses/me` · `/businesses/` | user | Business listing CRUD |
+
+### ingestion (`ingestion/urls.py`)
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
| GET | `/api/cron/ingest` | CRON_SECRET | Queue ingestion pipeline |
| POST | `/api/events/publish-approved` | key | Queue bulk publish |
+| POST | `/api/events/direct-submit` | JWT optional | Direct host event submission (broadcast SPA) |
+| GET/POST | `/admin/docs/pipeline-docs/` · `/admin/docs/admin-docs/` · `/admin/docs/publish-approved/` | staff | Pipeline/admin docs pages + publish-approved button |
+
+### broadcast (`broadcast/urls.py`)
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
| GET | `/broadcast/access` | — | Caller's tier + trial metadata (403 for invalid creds) |
| POST | `/broadcast/preview` · `/submit` | tier≥1 | Preview eligible sites / enqueue submission |
| POST | `/broadcast/ai-autofill` | tier≥2 | LLM field extraction from free text |
| POST | `/broadcast/direct-recipe` | tier≥1 | Recipe JSON for a site (no job) |
| GET/POST | `/broadcast/jobs/[/retry\|/submit-real\|/cancel]` | tier≥1 | Job status + lifecycle ops |
| GET | `/broadcast/jobs//screenshots/` · `/manual/` | tier≥1 | Screenshot / manual-review recipe |
-| GET/POST | `/admin/docs/...` · `/admin/` | staff | Docs pages + django-unfold admin |
+
+### admin
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| GET/POST | `/admin/` | staff | django-unfold admin |
## Management Commands
-- **events:** `devserver` (auto-port runserver), `seed_dev`, `healthcheck [--json]`, `delete_user --email`, `send_digest`, `send_test_digest --email`, `send_weekly_digest`.
+- **events:** `devserver` (auto-port runserver), `seed_dev`, `healthcheck [--json]`, `delete_user --email`.
+- **newsletter:** `send_digest`, `send_weekly_digest`, `send_test_digest --email`.
- **ingestion:** `ingest_events` (full pipeline; `--skip-*`, `--shard N/M`), `cleanup_old_events`.
- **broadcast:** `run_broadcast_worker [--once]` (debug helper — drains one submission without Celery, not a service entrypoint), `broadcast_dry_run --site --fixture`, `capture_broadcast_form `, `check_recipes [--live]`, `scaffold_adapter --url --key`, `set_broadcast_access <0|1|2>`, `generate_access_code [--tier] [--label] [--expires] [--uses|--unlimited]`, `list_access_codes`, `revoke_access_code
- Per-collector and per-broadcast event counts for the selected window. Click a row to see the
- underlying raw events (or broadcast submissions).
+ Summary tiles, then per-collector and per-broadcast event counts, for the selected window —
+ everything below loads with zero clicks. Use a source's Probe button to dry-run its
+ fetch/parse/write path.
@@ -215,7 +304,8 @@
Ingestion & Broadcast Monitor
Run history unavailable: ingestion_sourcerun doesn’t exist here.
This database is behind on migrations — it hasn’t had
- 0014_sourcerun applied. The Runs tab is empty and health levels fall back to
+ 0014_sourcerun applied. The Runs tab (in a source’s Probe dialog) is
+ empty and health levels fall back to
last-polled staleness only. Everything else on this page is accurate, and the probe still
works — it never reads SourceRun.
@@ -225,12 +315,64 @@
Ingestion & Broadcast Monitor
The table exists, but this connection’s role can’t read it —
ALTER DEFAULT PRIVILEGES doesn’t follow tables created by a different
role, so a newly migrated table needs its grant re-run:
- GRANT SELECT ON ingestion_sourcerun TO monitor_readonly;. The Runs tab is
- empty and health levels fall back to last-polled staleness only. Everything else on this
- page is accurate.
+ GRANT SELECT ON ingestion_sourcerun TO monitor_readonly;. The Runs tab (in a
+ source’s Probe dialog) is empty and health levels fall back to last-polled staleness
+ only. Everything else on this page is accurate.
{% endif %}
+
+
+
Funnel (window)
+
+
{{ summary.funnel.raw }}Raw
+
{{ summary.funnel.published }}Published
+
{{ summary.funnel.held_for_review }}Held for review
+
{{ summary.funnel.duplicate }}Duplicate
+
{{ summary.funnel.no_town }}No town
+
+
+
+
+
Health rollup
+
+
{{ summary.health.error }}error
+
{{ summary.health.warn }}warn
+
{{ summary.health.unknown }}unknown
+
{{ summary.health.ok }}ok
+
{{ summary.health.inactive }}inactive
+
{{ summary.health.push }}push
+
+
+
+
+
Outbound broadcast
+
+
{{ outbound.total|default:0 }}Submissions
+ {% for status, count in outbound.by_status.items %}
+
{{ count }}{{ status }}
+ {% endfor %}
+ {% for status, count in outbound.targets_by_status.items %}
+
{{ count }}targets: {{ status }}
+ {% endfor %}
+
+
+
+
+
Freshness
+
+
{{ summary.freshness.newest_raw_created_at|default:"—" }}Newest raw event
+ Sources below are sorted worst-first by health (error → warn → unknown → ok
+ → inactive/push) — _source_rows already sorts them that way, so this
+ page doesn't re-sort client-side. Recent events for each section paginate beneath its table.
+