Every storefront, including nested subdomains, uses its matching api.<hostname> endpoint.
7.8 KiB
Deployment — server provisioning, CD, TLS
Frontend only. The backend service (:8080) is a separate developer's responsibility. API hostnames are separate reverse proxies and will return 502 until their upstream exists.
Multi-tenant, one bundle. Every customer domain is served by the same build. The SPA resolves its tenant from the Host header (BACKEND-HANDOFF §1a). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
The SPA derives its API origin from the complete storefront hostname:
example.com uses api.example.com, and store1.example.com uses
api.store1.example.com. Provision DNS, TLS, reverse proxying, and CORS for
every derived API hostname. A single-label wildcard certificate does not cover
nested names such as api.store1.example.com.
1. Files
| Path | Purpose |
|---|---|
scripts/deploy/server-setup.sh |
One-time server provisioning. Idempotent. Run as root. |
scripts/deploy/add-domain.sh |
Attach one domain + issue TLS. Run per domain, as root, after DNS resolves. |
.github/workflows/deploy.yml |
CD: build → upload → atomic swap → verify. Triggers on push to main. |
2. Layout on the server
/srv/marketplaces/
├── releases/
│ ├── a1b2c3d4e5f6/frontend/ <- one directory per deployed commit
│ └── ... (last 5 kept)
└── current -> releases/a1b2c3d4e5f6
nginx root is /srv/marketplaces/current/frontend. Activation is a symlink swap, so no request is ever served from a half-written directory, and a rollback is a symlink change rather than a rebuild.
3. First-time setup
3.1 Generate a CI deploy key
On your machine, not on the server:
ssh-keygen -t ed25519 -C "ci@marketplaces" -f ./marketplaces_deploy -N ""
Two files result. marketplaces_deploy.pub goes to the server; marketplaces_deploy (private) goes into CI secrets and nowhere else.
3.2 Provision the server
Copy scripts/deploy/ to the server and run:
sudo bash server-setup.sh --pubkey "$(cat marketplaces_deploy.pub)"
This installs nginx + certbot, creates a key-only deploy user with no password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and grants deploy exactly one sudo right: systemctl reload nginx.
Verify before continuing:
curl -I http://<server-ip>/health
Expect 200. A placeholder page is served until the first real deploy.
3.3 Capture the host key
ssh-keyscan -H <server-ip>
The output is the DEPLOY_KNOWN_HOSTS secret. Pinning it means a rebuilt or impersonated server fails the deploy instead of being trusted silently.
3.4 Add CI secrets
| Secret | Value |
|---|---|
DEPLOY_HOST |
server IP or hostname |
DEPLOY_USER |
deploy |
DEPLOY_SSH_KEY |
contents of the private key file |
DEPLOY_KNOWN_HOSTS |
output of ssh-keyscan -H <server-ip> |
3.5 Deploy
Push to main, or run the workflow manually with a ref. The workflow refuses to swap the symlink unless the uploaded release contains an index.html, so a failed upload leaves the previous release serving.
4. Domains and TLS — dynamic by default
Domains arrive continuously: one today, five tomorrow. Nothing here requires a person per domain.
HTTP already needs zero configuration. The nginx catch-all serves any Host, and the SPA resolves its tenant from that header. Point a domain's A record at the server and it works over port 80 immediately. Only TLS needs a certificate per name — that is the whole problem this section solves.
Two mechanisms, used together:
4.1 Wildcard — tenants on our own apex
One certificate covers every <slug>.<apex>. A new tenant subdomain is then live over HTTPS the moment DNS resolves, with no certificate work at all.
sudo bash setup-wildcard-tls.sh \
--apex marketplaces.example.com \
--email ops@example.com \
--dns cloudflare --creds /root/cloudflare.ini
Wildcards require DNS-01 validation, so certbot must write a _acme-challenge TXT record. With a provider plugin (cloudflare, route53) renewal is unattended. --dns manual works but prompts for a TXT record at every renewal — fine to prove the setup out, not acceptable as a steady state.
Hostinger has no certbot plugin. If DNS lives there: either move DNS to a provider that has one (Cloudflare is free, minutes of work), or drive issuance from the Phase 9 domain-automation API once it exists.
4.2 Reconciler — tenants on their own domains
A wildcard cannot cover a customer's own domain. sync-domains.sh runs on a 10-minute timer and reconciles the live set against a desired list:
- issues certificates for domains that lack one
- skips domains whose certificate has more than 30 days left
- skips subdomains already covered by
WILDCARD_APEX - leaves domains alone while their DNS has not propagated yet, and retries next tick
- disables server blocks for domains removed from the source — without deleting the certificate, so re-adding one later is instant
- caps issuance per run, so a misconfigured source cannot burn the weekly ACME budget in a single pass
Configure /etc/marketplaces/domains.env:
DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
CERTBOT_EMAIL=ops@example.com
MAX_ISSUE_PER_RUN=10
Then:
sudo systemctl enable --now marketplaces-domains.timer
sudo /srv/marketplaces/bin/sync-domains.sh --dry-run # see the plan, change nothing
Adding a domain becomes: append a line to /etc/marketplaces/domains.txt (or add the row in the backend registry), point DNS, wait one tick.
4.3 Backend-driven, once Phase 9 ships
Point the reconciler at the registry instead of a file and the loop closes — MarketplaceDomain already carries exactly the statuses this needs (planned → dns_pending → ssl_pending → active → failed):
DOMAINS_SOURCE=https://api.example.com/api/admin/v2/domains
DOMAINS_API_TOKEN=...
The script accepts a bare JSON array of hostnames, or objects with domain + status, in which case it acts only on active rows. A fetch failure aborts the run rather than reading as "remove every domain."
4.4 One-off
For a single domain, outside the reconciler:
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
4.5 Verify
curl -I https://shop.example.com/health
sudo certbot certificates
journalctl -u marketplaces-domains.service --since "1 hour ago"
5. Rollback
ssh deploy@<server-ip>
ls -1dt /srv/marketplaces/releases/*/ # newest first
ln -sfnT /srv/marketplaces/releases/<sha> /srv/marketplaces/current.new
mv -Tf /srv/marketplaces/current.new /srv/marketplaces/current
sudo systemctl reload nginx
Only the last 5 releases are retained. Older ones need a rebuild from the tag.
6. Operational checks
curl -I http://<host>/health # 200 from nginx
readlink -f /srv/marketplaces/current # which commit is live
sudo nginx -t # config valid
systemctl status nginx certbot.timer # both active
sudo tail -f /var/log/nginx/marketplaces.error.log
7. Known limits
/api/502s until the backend runs. Expected. nginx proxies to127.0.0.1:8080; nothing listens there yet.- No staging environment.
maingoes straight to production. Adding one means a second server plus astagingbranch trigger. - No smoke test beyond HTTP 200. The verify step confirms nginx serves the shell, not that the app boots. A real check needs the E2E harness from Track Q.
- Caching.
index.htmlisno-store; hashed assets areimmutablefor a year. A deploy therefore takes effect on the next page load, with no cache purge.