12 KiB
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)
- GitHub Actions
registry-deployworkflow fires on push togenesiswhen paths underpackages/react/react-ui/**,packages/react/react-ui-theme-*/**,packages/react/react-ui-pack-*/**, orops/registry/**change. - The
buildjob runsbun install --frozen-lockfile, builds the registry JSON viabun run ops/registry/scripts/build-registry.ts, builds the Storybook static, and uploads the staged artifact. - The
deployjob downloads the artifact, configures the SSH private key from theOVH_SSH_PRIVATE_KEYsecret, and rsyncs both trees to the OVH host asdeploy-registry. The previous live contents are snapshotted to/srv/registry/.previous/<utc-timestamp>/via rsync's--backup --backup-dir=...flags. - A post-deploy probe hits
https://ui.suaveplan.com/healthup 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-registryhas a single sudoers rule limited torsyncwrites 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, andPermissions-Policy: interest-cohort=(). Storybook adds a restrictive Content-Security-Policy. - No
.well-knownleakage. Caddy's ACME handler binds/.well-known/acme-challenge/*internally; everything else under.well-knownis denied. - No secrets in committed files.
.env.examplecarries placeholders only. Real values live in GitHub Actions secrets namedOVH_SSH_HOST,OVH_SSH_USER,OVH_SSH_PRIVATE_KEY.