Files
suaveplan/ops/registry
2026-05-27 23:29:53 +01:00
..
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00
2026-05-27 23:29:53 +01:00

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/<component> 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

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

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

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

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:

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:

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

# 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

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/<utc-timestamp>/ 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/<utc-timestamp>/ after every deploy. rollback.sh atomic-swaps the current tree with the most recent snapshot:

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:

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

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

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

# 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 https://hstspreload.org/.
  • 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.