Files
marketplaces/docs/DEPLOYMENT.md
sdarbinyan 28861953c8
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
ci: add frontend CD pipeline, server provisioning, TLS scripts
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>
2026-08-18 11:42:06 +04:00

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 to 127.0.0.1:8080; nothing listens there yet.
  • No staging environment. main goes straight to production. Adding one means a second server plus a staging branch 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.html is no-store; hashed assets are immutable for a year. A deploy therefore takes effect on the next page load, with no cache purge.