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>
5.0 KiB
Deployment — server provisioning, CD, TLS
Frontend only. The backend service (:8080) is a separate developer's responsibility; nginx already proxies /api/ to it and will 502 until it 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.
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. TLS
No certificate is issued during provisioning, because certbot cannot validate a domain that does not yet point at the server.
Per domain, once its A record resolves here:
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
This writes a server block for that domain (same root — it exists only to give certbot a concrete server_name), issues the certificate, enables the HTTP→HTTPS redirect, and enables certbot.timer for renewal.
Verify:
curl -I https://shop.example.com/health
sudo certbot certificates
On wildcards: add-domain.sh uses HTTP-01, which cannot issue wildcards. If tenants all live under one apex (*.marketplaces.example.com), a DNS-01 wildcard is fewer moving parts — but it requires API credentials for the DNS provider and a different certbot plugin. Not set up here; raise it when the tenant count makes per-domain issuance annoying.
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.