# `ops/registry/` — `ui.suaveplan.com` deployment runbook This directory ships the Docker + Caddy scaffolding that hosts the `@suaveplan/react-ui` shadcn registry and Storybook static at `https://ui.suaveplan.com`. ## What this is Two static trees served from a single Caddy 2 container behind auto-issued Let's Encrypt TLS: | Path | Source on host | Purpose | | --------------- | ------------------------------- | ----------------------------------------------------------- | | `/r/*` | `/srv/registry/r/` | shadcn `registry-item` JSON manifests (`{name}.json`). | | `/storybook/*` | `/srv/registry/storybook/` | Hosted Storybook static build of every published component. | | `/health` | (in-process Caddy responder) | Returns `200 OK` body `ok` for health probes. | Consumers point their `components.json` at `https://ui.suaveplan.com/r/{name}.json` and run `npx shadcn@latest add @suaveplan/` to source-eject into their own `src/components/ui/` tree. ## File index | File | Purpose | | --------------------- | ------------------------------------------------------------------------------------ | | `Dockerfile` | `caddy:2-alpine`, validated Caddyfile baked in, healthcheck on `/health`. | | `Caddyfile` | `ui.suaveplan.com` site block; TLS 1.2+; HSTS preload; `/r/*` + `/storybook/*` + `/health`. | | `docker-compose.yml` | Single `registry` service; bind-mounts `/srv/registry`; named cert volumes; `restart: unless-stopped`. | | `.env.example` | Placeholder env. Real values live in GitHub Actions secrets — never commit them. | | `rollback.sh` | One-shot atomic-swap of live tree with most recent snapshot. Idempotent. | | `scripts/build-registry.ts` | Bun script that emits the per-component JSON tree from `@suaveplan/react-ui` source. | ## First-time OVH setup (USER MANUAL — required before first deploy) These steps run ONCE when bringing the deployment online. They cannot be scripted in this change because they install secrets and create a host-side user. Run them in order. ### 1. SSH to OVH as root and create the scoped deploy user ```bash ssh root@ovh # Create a system user with a real shell so rsync over ssh works. useradd -r -m -s /bin/bash deploy-registry # Allow the user to run rsync into /srv/registry only — no shell sudo. cat >/etc/sudoers.d/deploy-registry <<'EOF' deploy-registry ALL=(root) NOPASSWD: /usr/bin/rsync --server -* /srv/registry/* EOF chmod 0440 /etc/sudoers.d/deploy-registry visudo -c -f /etc/sudoers.d/deploy-registry # validates syntax # Add to docker group so the user can run `docker compose ...` for # the rare in-place config change. The deploy itself does NOT need # docker — rsync into the bind-mounted directory is enough. usermod -aG docker deploy-registry ``` ### 2. Lay out the host directory tree ```bash mkdir -p /srv/registry/{r,storybook,.previous,ops} chown -R deploy-registry:deploy-registry /srv/registry/r /srv/registry/storybook /srv/registry/.previous # Ops files (Dockerfile, Caddyfile, docker-compose.yml, rollback.sh) # stay root-owned; deploy-registry has no business overwriting them. chown -R root:root /srv/registry/ops ``` ### 3. Copy the ops scaffolding to the host From your laptop (not from the OVH host): ```bash rsync -avz --delete \ ops/registry/Dockerfile \ ops/registry/Caddyfile \ ops/registry/docker-compose.yml \ ops/registry/rollback.sh \ ops/registry/.env.example \ root@ovh:/srv/registry/ops/ ssh root@ovh "chmod +x /srv/registry/ops/rollback.sh" ``` ### 4. Bring up Caddy ```bash ssh root@ovh cd /srv/registry/ops docker compose up -d --build # Wait 30–60 s for Let's Encrypt to issue the cert. Then verify: curl -fsSL https://ui.suaveplan.com/health # → ok docker compose ps # status: healthy ``` If the cert does not issue: ```bash docker compose logs --tail=200 registry | grep -iE 'acme|cert|tls' ``` The most common cause is port 80 not being open from the public internet — Let's Encrypt's HTTP-01 challenge needs it. ### 5. Generate the CI deploy SSH keypair On your laptop: ```bash ssh-keygen -t ed25519 -f ~/.ssh/registry_deploy -C "github-actions-registry-deploy" # Install the public key for the deploy user. ssh-copy-id -i ~/.ssh/registry_deploy.pub deploy-registry@ovh # Verify it works AND that root login is unaffected. ssh -i ~/.ssh/registry_deploy deploy-registry@ovh whoami # → deploy-registry ``` ### 6. Add the secrets to GitHub Actions ```bash # Run from inside the genesis checkout. gh secret set OVH_SSH_HOST --body "ovh.example.com" gh secret set OVH_SSH_USER --body "deploy-registry" gh secret set OVH_SSH_PRIVATE_KEY < ~/.ssh/registry_deploy # Confirm three secrets are set. gh secret list | grep -E '^OVH_' ``` ### 7. Verify DNS ```bash dig +short ui.suaveplan.com # Should print the OVH host's public IP. ``` The first push to `genesis` that touches `packages/react/react-ui/**` or `ops/registry/**` triggers the `registry-deploy` workflow and publishes to the live host. ## On-deploy flow (CI) 1. GitHub Actions `registry-deploy` workflow fires on push to `genesis` when paths under `packages/react/react-ui/**`, `packages/react/react-ui-theme-*/**`, `packages/react/react-ui-pack-*/**`, or `ops/registry/**` change. 2. The `build` job runs `bun install --frozen-lockfile`, builds the registry JSON via `bun run ops/registry/scripts/build-registry.ts`, builds the Storybook static, and uploads the staged artifact. 3. The `deploy` job downloads the artifact, configures the SSH private key from the `OVH_SSH_PRIVATE_KEY` secret, and rsyncs both trees to the OVH host as `deploy-registry`. The previous live contents are snapshotted to `/srv/registry/.previous//` via rsync's `--backup --backup-dir=...` flags. 4. A post-deploy probe hits `https://ui.suaveplan.com/health` up to five times; the workflow fails if none succeed. Caddy serves the new files immediately — no `caddy reload` is needed for static-file changes. Reload is only required when `Caddyfile` itself changes; in that case bump the image and `docker compose up -d --build` from the OVH host. ## Rollback The previous live tree is preserved at `/srv/registry/.previous//` after every deploy. `rollback.sh` atomic-swaps the current tree with the most recent snapshot: ```bash ssh root@ovh "bash /srv/registry/ops/rollback.sh" ``` Re-running the script roll-forwards (i.e., promotes the just-parked tree back to live). The script exits non-zero with a clear message if no snapshot exists. ## Cert renewal Caddy auto-renews ~30 days before expiry. To verify: ```bash ssh root@ovh "docker compose -f /srv/registry/ops/docker-compose.yml exec registry \ caddy list-certificates --address localhost:2019" 2>&1 | grep -A1 ui.suaveplan.com ``` To force-renew (rare; only if a CT-log issue or a botched serial): ```bash ssh root@ovh "docker compose -f /srv/registry/ops/docker-compose.yml restart registry" ``` Cert state lives in the `caddy_data` named volume so a restart does not re-trigger ACME unless the cert is genuinely expired. ## Log inspection ```bash # Recent access + error logs. ssh root@ovh "docker logs --tail=500 suaveplan-registry" # Live tail. ssh root@ovh "docker logs -f --tail=50 suaveplan-registry" # 5xx grep across the last 24 h (assumes JSON log driver). ssh root@ovh "docker logs --since=24h suaveplan-registry 2>&1 \ | grep -E '\"status\":(5[0-9]{2})'" ``` ## Troubleshooting | Symptom | Likely cause | Fix | | ------------------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `curl https://ui.suaveplan.com/health` hangs | Caddy not running or firewall blocking 443. | `docker compose ps`; check `iptables -L -n -v` for `DROP` on tcp/443. | | 502 Bad Gateway | Should not happen for static file serving. Caddy crashed. | `docker logs suaveplan-registry`; restart: `docker compose restart registry`. | | 503 Service Unavailable | Caddy is starting up or healthcheck is failing. | Wait 15 s for `start_period`; if it persists, inspect `docker logs`. | | 504 Gateway Timeout | Static file serve does not produce 504. Check upstream proxy if any. | Confirm there is no Cloudflare or other reverse proxy in front of OVH. | | Cert renewal fails | ACME HTTP-01 challenge can't reach port 80. | Verify port 80 is open publicly; check no other process binds it. | | GH Action `Permission denied (publickey)` | Public key not in `deploy-registry`'s `authorized_keys`, or key rotated. | Re-run `ssh-copy-id`; re-add `OVH_SSH_PRIVATE_KEY` secret. | | Empty registry served (`{ "items": [] }`) | `@suaveplan/react-ui` not yet present on genesis (pre-Wave-2). | Expected; the script emits an empty index when no source components exist. No action. | ## Manual ad-hoc admin `ssh root@ovh` remains available for any out-of-band admin work (restarting Docker, inspecting filesystem state, editing the Caddyfile in place during incident response). CI deliberately does not use root to keep its blast radius minimal — even if the CI SSH key leaks, the attacker can only overwrite the public registry contents, not own the host. ## Secret rotation To rotate the deploy SSH key: ```bash # 1. Generate a new keypair locally. ssh-keygen -t ed25519 -f ~/.ssh/registry_deploy_v2 -C "github-actions-registry-deploy-v2" # 2. Add the new public key to OVH alongside the old one. ssh-copy-id -i ~/.ssh/registry_deploy_v2.pub deploy-registry@ovh # 3. Replace the secret in GitHub Actions. gh secret set OVH_SSH_PRIVATE_KEY < ~/.ssh/registry_deploy_v2 # 4. Trigger a deploy (push or `gh workflow run registry-deploy.yml`). # Confirm the workflow succeeds. # 5. Remove the old public key from OVH. ssh deploy-registry@ovh "sed -i '/registry-deploy[^-]/d' ~/.ssh/authorized_keys" ``` To rotate `OVH_SSH_HOST` (host migration) or `OVH_SSH_USER` (user rename): set the new value via `gh secret set` then trigger a deploy. The next workflow run picks the updated secret automatically. ## Security model - **Read-only registry.** Caddy serves files only; there is no write endpoint exposed publicly. The bind mount is `:ro`. - **Scoped non-root deploy user.** `deploy-registry` has a single sudoers rule limited to `rsync` writes into `/srv/registry/`. No shell sudo, no general docker control beyond restart. - **TLS 1.2 minimum.** The Caddyfile pins `protocols tls1.2 tls1.3`. TLS 1.0 and 1.1 are rejected by the server. - **HSTS preload-eligible.** `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`. After a 90-day soak with no downgrade incidents, submit the domain to . - **Header hardening.** Every response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, and `Permissions-Policy: interest-cohort=()`. Storybook adds a restrictive Content-Security-Policy. - **No `.well-known` leakage.** Caddy's ACME handler binds `/.well-known/acme-challenge/*` internally; everything else under `.well-known` is denied. - **No secrets in committed files.** `.env.example` carries placeholders only. Real values live in GitHub Actions secrets named `OVH_SSH_HOST`, `OVH_SSH_USER`, `OVH_SSH_PRIVATE_KEY`.