diff --git a/apps/docs/src/content/docs/topics/deployment.mdx b/apps/docs/src/content/docs/topics/deployment.mdx
index 0bf03d41..6641d458 100644
--- a/apps/docs/src/content/docs/topics/deployment.mdx
+++ b/apps/docs/src/content/docs/topics/deployment.mdx
@@ -1,301 +1,188 @@
---
title: Deployment
-description: Boring deployment. Single-host VPS, Docker, one repo created from the GitHub template, GHCR images, one prod profile. HTTPS via ACME, perimeter locked by Cloudflare.
+description: Production deploy in four steps — accounts, secrets into 1Password, provision (OpenTofu or manual), verify. Get to a live HTTPS site in ~30 minutes.
---
import { Aside } from "@astrojs/starlight/components";
import CommandRun from "../../../components/docs-kit/CommandRun";
import PageIntro from "../../../components/docs-kit/PageIntro";
-import SignalGrid from "../../../components/docs-kit/SignalGrid";
-import FaqGroup from "../../../components/FaqGroup.tsx";
-import FaqItem from "../../../components/FaqItem.tsx";
- BoringStack runs on a VPS you control: Docker Compose, GHCR images, Traefik
- TLS, and Cloudflare at the edge. Use the GitHub template to create your repo,
- fill compose/.env, then boot the production profile manually or let OpenTofu
- do first provisioning.
+ Four steps to a live HTTPS site. Same prep for both paths — OpenTofu
+ (one `tofu apply`, recommended) or manual (SSH, `compose up -d`).
-## The mental model
-
-Push to `main` with changes under `apps/api` or `apps/ui`. Path-filtered release workflows build Docker images and push them to `ghcr.io//-api:latest` and `ghcr.io//-ui:latest`. WUD on the VPS detects the new tags and auto-deploys app containers (`api`, `ui`). Base images remain notify-only and operator-applied. Traefik handles TLS via Let's Encrypt; Cloudflare proxies all traffic to your single apex domain via same-origin path routing.
-
-
+## 1 — Accounts
+
+| Account | Why |
+|---|---|
+| [Cloudflare](https://dash.cloudflare.com/sign-up) | DNS + edge proxy + TLS termination. Domain must be on a Cloudflare zone. |
+| [Hetzner Cloud](https://accounts.hetzner.com/signUp) | The VPS. New accounts can sit in fraud review ~1 day — start here. |
+| [GitHub](https://github.com/join) | Holds your fork; publishes images to GHCR. |
+| [1Password](https://1password.com/) | Single source of truth for every secret below. Alternatives: Vault / Infisical / Doppler / SOPS — same shape, different CLI. |
+
+## 2 — Secrets into 1Password
+
+Pattern: **generate / mint on your laptop, push to 1Password, then never re-type.** Raw values never live in a file on the VPS — `op://` references do.
-## Design choices
-
-
-
- One VPS carries real traffic for years; scale out when you need it.
-
-
- Reproducible deploys; the VPS pulls images instead of running `npm install` on box.
-
-
- `api`/`ui` auto-deploy from GHCR tags; base services stay notify-only and
- human-reviewed.
-
-
- TLS on the origin without a separate load-balancer bill.
-
-
- One cert for `/` and `/api/*`; no `api.` subdomain.
-
-
- Only Cloudflare reaches the origin; port scans hit a closed firewall.
-
-
- Small runtime image without a shell in prod.
-
-
-
-## Boot-time guardrails
-
-The api enforces a set of rules at startup before it binds the port. A
-misconfigured deploy fails loud here, not silently with a known-weak
-secret. Every rejection comes with the exact command that fixes it.
-
-
-
- `JWT_SECRET` and `MFA_ENCRYPTION_KEY` must be set and must not look
- like a placeholder. The validator rejects empty strings, anything
- starting with `replace-with-` / `change-me-` / `your-` / `example-`,
- anything containing `placeholder`, and a short allow-list of
- well-known test/migration defaults shipped with the template. Fix:
- `openssl rand -base64 48` (JWT) / `openssl rand -base64 32` (MFA),
- store in your secret manager, set on the vps.
-
-
- `EMAIL_FROM` on `example.com` / `example.org` / `example.net` /
- `localhost` is rejected in production — every email provider drops
- sends from unverified domains, the validator catches it before the
- first send instead of in a queue worker's logs. Fix: set it to an
- address on a domain you control and verified with the provider.
-
-
- Rejected in production because transactional email (verify, reset,
- notifications) would attempt one inline send per request and lose
- the message on any provider blip. Fix: leave the default (`true`)
- or omit the variable.
-
-
- Required when queues, the Valkey cache provider, the notification
- SSE channel, or any OAuth provider is on — those subsystems read or
- publish through Valkey. Fix: set a password on the Valkey instance
- and reference it in your secret manager.
-
-
- Configuring half of a pair is always a mistake. Fix: set both
- `*_CLIENT_ID` and `*_CLIENT_SECRET` for each enabled provider, or
- leave both empty.
-
-
- Production CORS must be HTTPS-only and explicit. Empty is fine
- (same-origin deploy). Fix: list the exact SPA origin(s), HTTPS,
- no `*`.
-
-
-
-## First-time wiring
-
-Three things need configuring before the first deploy. The release workflows handle everything else from your repo URL.
-
-### 1. Publish images to GHCR
-
-The API and UI release workflows run on push to `main` when their app paths change. They use `${{ github.event.repository.name }}` plus an app suffix, so a repo named `acme-stack` publishes `ghcr.io//acme-stack-api:latest` and `ghcr.io//acme-stack-ui:latest` automatically. The default `GITHUB_TOKEN` has `packages: write` via the workflow grant, so the first push from a fresh template clone works without secret setup.
-
-After the first publish, **make the GHCR package public** so the VPS can pull without credentials:
-
-1. Go to your repo's Packages tab and open both containers: `-api` and `-ui`.
-2. Package settings → Change visibility → Public.
-
-Without this, downstream consumers (your VPS) need a pull credential.
-
-### 2. Point the prod compose stack at your images
-
-In `infra/compose/compose/.env`:
+**Generate stack secrets** locally:
```bash
-IMAGE_OWNER=acme
-API_IMAGE_NAME=acme-stack-api
-UI_IMAGE_NAME=acme-stack-ui
+JWT=$(openssl rand -base64 48) # JWT signing
+MFA=$(openssl rand -base64 32) # MFA encryption — CANNOT be rotated post-enrolment
+PG=$(openssl rand -base64 32) # Postgres password
+VK=$(openssl rand -base64 32) # Valkey password
```
-Kept the canonical upstream repo name? Set `IMAGE_OWNER`; the defaults are `boringstack-api` and `boringstack-ui`.
+**Mint the Hetzner API token.** Hetzner Console → your project → **Security** → **API Tokens** → **Generate**. Permission: **Read & Write**. Copy now — Hetzner shows it once.
-### 3. (Optional) Wire OpenTofu bootstrap
+**Mint the Cloudflare API token.** Cloudflare → **My Profile** → **API Tokens** → **Create Token** → **Custom Token**. Permissions: `Zone:DNS:Edit`, `Zone:Zone Settings:Edit`, `Zone:Rulesets:Edit`. Zone Resources: include the specific zone (not all zones). Copy now. Grab the **Zone ID** and **Account ID** from the zone overview's right sidebar while you're there.
-If using the OpenTofu path, `terraform.tfvars` under `infra/bootstrap`:
+**SSH key** (skip if you have one):
-```hcl
-monorepo_repo = "https://github.com/acme/acme-stack"
-domain = "acme.com"
-# ... other vars per terraform.tfvars.example
+```bash
+ssh-keygen -t ed25519 -C "boringstack-vps" -f ~/.ssh/boringstack
```
-The module derives `IMAGE_OWNER`, `API_IMAGE_NAME=-api`, and `UI_IMAGE_NAME=-ui` from that URL and renders them into `compose/.env` on the VPS. You don't set them twice.
-
-## First-deploy checklist
+**Vault layout** (in 1Password GUI, or via CLI — see snippet below):
+
+```text
+Vault: Production
+├── Auth jwt_secret · mfa_encryption_key
+├── Postgres password
+├── Valkey password
+├── Hetzner api_token
+├── Cloudflare api_token · zone_id · account_id
+├── SSH public_key · private_key
+└── ACME email ← real address; Let's Encrypt rejects example.com
+```
-Use this once, after Quickstart and before the first production boot:
+
-