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: + -