The Reconcile tenant API domains step ran on every push to main. On the
production host that is actively harmful: api.gorbushka.market already has
a hand-written vhost, and configure-api-domain.sh writes its own file per
domain - so the step would hand nginx a second server block for a
server_name that already has one and re-run certbot against a live API,
once per deploy. Shipping frontend files needs none of it. Gate it behind
a workflow_dispatch input, off by default, for standing up a NEW base
domain.
This also shrinks the secrets a normal deploy requires to four
(DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS);
STOREFRONT_DOMAINS, CERTBOT_EMAIL and BACKEND_UPSTREAM are now read only
on the opt-in path.
Document the production host as it actually is: provisioned by hand before
server-setup.sh existed, per-domain vhosts rooted at
/var/www/dexarmarket/browser, which is now a symlink to
/srv/marketplaces/current/frontend. Before 2026-08-22 it pointed straight
at a pinned release with no `current` in between, so releases 14d46ce and
98c39f6 uploaded successfully and were never served.
Adding a domain was a manual per-domain script run. With domains arriving
continuously that does not hold, so certificate issuance is now automatic.
HTTP already needed no work: the nginx catch-all serves any Host and the SPA
resolves its tenant from that header. Only TLS needed a name-by-name step.
Two mechanisms:
- setup-wildcard-tls.sh issues one DNS-01 wildcard for *.<apex>, after which a
new tenant subdomain is live over HTTPS with zero certificate work.
- sync-domains.sh reconciles tenant-owned domains against a desired list on a
10-minute timer: issues what is missing, skips certificates with >30 days
left, skips names already covered by the wildcard, waits out unpropagated
DNS, and caps issuance per run so a bad source cannot burn the weekly ACME
budget.
Safety properties worth stating: a failed fetch of the desired list aborts the
run rather than reading as "remove every domain"; removing a domain disables
its server block but keeps the certificate, so re-adding is instant; malformed
hostnames are rejected before reaching certbot or an nginx server_name.
The source is pluggable - a file today, the Phase 9 domain registry once it
exists, whose MarketplaceDomain statuses already match what this needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no CD: pushing to main deployed nothing, and deploys were a manual
copy onto the server. This adds the missing half.
- .github/workflows/deploy.yml - build, upload to a per-commit release
directory, swap the symlink atomically, reload nginx, verify over HTTP.
The swap only happens after the upload is verified to contain index.html,
so a failed deploy leaves the previous release serving.
- scripts/deploy/server-setup.sh - idempotent one-time provisioning: nginx,
certbot, ufw, and a key-only deploy user whose sole sudo right is
"systemctl reload nginx".
- scripts/deploy/add-domain.sh - per-domain server block plus TLS issuance,
run once a domain's A record resolves to the server.
- docs/DEPLOYMENT.md - setup order, required CI secrets, rollback, limits.
Also adds .gitattributes: the shell scripts were being checked out with CRLF
endings, which makes bash fail on the shebang line on Linux.
Host keys are pinned via DEPLOY_KNOWN_HOSTS rather than trusted on first use.
No credentials are committed; all four deploy secrets are supplied by CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>