# Stream E: production environment (rewritten 2026-09-05 Sat, against build plan v3 §7/§9/§10)
> **Status 11 Sep:** E1 to E6 done; production is live on dutyfreeprofessor.com from the droplet `bwlive` behind the app's own sign-in (see the 11 Sep handoff and `main/docs/RUNBOOK.md` Production). E7's second pass waits for the flip to public. Earlier: E2 landed (nightly dump cron). Rian stands production up on 10 Sep on the launch domain behind a domain password; read `issue-production-goes-live-on-10-sep-behind-a-domain-password` and `decide-the-owner-sign-in-stopgap-one-password-and-one-secret-in` on the running list first. The body below is the brief as asked on 5 Sep.
**Runs:** with rian present. Money, accounts, DNS and the first SSH key are his; the session
prepares, checks and documents. Not a `/loop`: start it with `claude` then `/stream-e`.
**Decided 2026-09-05:** Fri 11 = option (a), so production live by Fri 11 is the target. DFP is
the first thing on the droplet; the droplet may later become rian's general live-hosting box
(running-list item `decide-hosting-figure`: the figure goes to rian two ways), so size with that in view but build for DFP alone.

## Goal
A production home separate from the development server: host, Postgres, domain, certificates,
backups with a rehearsed restore, monitoring that reaches rian, a deploy path from this
repository that is routine, and a runbook. Every rule in `/srv/CLAUDE.md` "Security-First
Behavior" applies to the droplet exactly as to this server.

## Tasks, in order (ids are `/plan` tasks; record each with `main/scripts/plan-set.py`)
1. **E1 Egress test (Sun 7, or today on the real droplet).** From the droplet's IP, with our
   declared UA (`fetch.USER_AGENT`), fetch `robots.txt` and one known product URL from every
   source in `registry.py` (21 hosts). Record per host: status, whether the body is the real
   page or a challenge, time. Write `.logs/runs/egress-<date>.md`. This decides §10 #7 (where
   collectors run): if DO IPs are treated worse than this server's, collectors stay here and
   write to production Postgres over Tailscale; otherwise they run on the droplet. Script lives
   in `deploy/egress-test.py` (stdlib + httpx only; no app import needed on the droplet).
2. **E3 Sizing and cost (Tue 8 at the latest).** Inputs: app 1 GB / 1 CPU, Postgres 512 MB,
   Playwright sidecar 2 GB / 1 CPU, nineteen airports, one collection at a time, Caddy. Give
   rian two figures: DFP alone, and DFP with headroom for a shared live-hosting box. **Decided
   2026-09-05 (rian): the $50 hold stands; the real cost is discussed with Adam after launch.**
   The figure is for rian's own planning.
3. **E4 Provision (Tue 8; rian's account and card).** Rian creates the droplet (Ubuntu 24.04
   LTS, his SSH key, DO cloud firewall: 22 from his Tailscale/IPs only, 80/443 from anywhere).
   Session then, over SSH as a non-root sudo user: unattended-upgrades, password login off,
   Tailscale joined, Docker + compose plugin, swap file, timezone UTC, hostname. Postgres runs
   in Docker on a volume, no published port. **Secrets:** stage `.app.env` on the droplet with
   `REPLACE_WITH_` placeholders at 0600; rian fills them in his own shell; verify by behaviour
   and `grep -c REPLACE_WITH_` only. Nothing pasted in chat, ever.
4. **E4b Deploy path + first deploy (Wed 9).** `deploy/production.sh`, run from this server:
   dump production first if a migration rides, rsync `main/`, `docker-compose.yml` +
   `docker-compose.production.yml`, `import/` and `public/` over SSH (Tailscale), then
   `docker compose up -d --build` there. The container runs `alembic upgrade head` on start.
   `PUBLIC_BASE_URL` set to the production host. Print what changed; exit non-zero on failure.
   Caddy on the droplet terminates TLS (house standard); app and db stay on an internal network.
5. **E4c Domain and Cloudflare (Thu 10; rian).** `dutyfreeprofessor.com` to the droplet. **Decided 2026-09-05 (rian): the zone is in his
   Cloudflare account with full access; Adam owns the domain registration.** Zone checklist
   from §7: Bot Fight Mode off, AI-bot blocking off, managed robots.txt off, cache rules bypass
   `/api/*` and `/owner/*`, SSL mode Full (strict). Certificates: Caddy via the Cloudflare DNS
   challenge (token in a 0600 file) so the proxy can stay orange; confirm the WPE placeholder
   stops resolving. Verify with `curl -sI https://dutyfreeprofessor.com/` and one product page.
6. **E5 Host cron (Fri 11).** On the machine the egress test picked: `app.cli audit` nightly,
   `verify --n 20` after any collect, `verify --n 200` weekly; nightly `pg_dump` on production
   with 14-day retention and one copy pulled to this server. State in `COLLECTORS.md` (via a
   Request to Docs) that this is not automated collection; collection stays hand-run.
7. **E6 Monitoring, runbook, restore (Tue 15).** External uptime check on `/api/health` and one
   product page, alert by email to rian; disk and memory alerts on the droplet. Append a
   "Production" section to `main/docs/RUNBOOK.md` (deploy, roll back = redeploy previous commit,
   restore from dump, rotate secrets). **Rehearse one restore** into a scratch database and
   record the time it took.
8. **E7 Go-live checklist (Wed 16; with rian).** On the production host, unauthenticated: curl
   every route in `App.tsx` and its `/api/*` reads and confirm the internal set is closed;
   no-store headers on HTML and JSON; `sitemap.xml` and `robots.txt` correct for the production
   host; egress from the production IP re-checked; backups running; alerts tested once.

## Owns
`deploy/*` (new), `docker-compose.production.yml` (new), the droplet, DNS, certificates,
backups, monitoring, cron, and the "Production" section of `main/docs/RUNBOOK.md`.

## Must not touch
Application source under `main/app` and `main/web`; `docker-compose.yml` beyond adding what
the production overlay needs (say so in the handoff); any Alembic migration.

## Ground rules
- Read first: `.logs/planning/streams/OVERNIGHT-RULES.md` (the hand-back and recording rules
  apply even though rian is present), `agents.md`, `brief.md`, build plan §2, §7, §10.
- Bindings on the droplet: app and db on an internal Docker network only; Caddy alone on
  80/443. Nothing on `0.0.0.0` but Caddy. Run the security checklist from `/srv/CLAUDE.md`
  before saying a step is done.
- Cost matters: $50/month was told to Adam as a hold; real numbers go to rian, who tells Adam.
- Before handing back: `main/check.sh` green if you touched the tree, commit prefixed `E:`,
  a handoff entry (≤25 lines), tasks recorded on `/plan`, requests for other streams in
  `.logs/decisions-for-rian.md` "Requests".
