ci: add frontend CD pipeline, server provisioning, TLS scripts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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>
This commit is contained in:
7
.gitattributes
vendored
Normal file
7
.gitattributes
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Anything executed by a Linux shell must keep LF endings. A CRLF checkout
|
||||||
|
# makes bash fail with "\r: command not found" on the very first line.
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
134
.github/workflows/deploy.yml
vendored
Normal file
134
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: Deploy Frontend
|
||||||
|
|
||||||
|
# Multi-tenant: one bundle serves every customer domain, so a single deploy
|
||||||
|
# updates all of them at once. There is no per-tenant build or per-tenant deploy.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: Branch or SHA to deploy
|
||||||
|
required: false
|
||||||
|
default: main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-frontend
|
||||||
|
cancel-in-progress: false # never abandon a half-finished release swap
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Enforce boundaries
|
||||||
|
run: npm run arch:check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build -- --configuration production
|
||||||
|
|
||||||
|
- name: Resolve build output
|
||||||
|
id: dist
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# @angular/build:application emits into dist/<name>/browser.
|
||||||
|
# Fall back to the flat layout so this survives a builder change.
|
||||||
|
if [ -d dist/dexarmarket/browser ]; then
|
||||||
|
DIR=dist/dexarmarket/browser
|
||||||
|
elif [ -f dist/dexarmarket/index.html ]; then
|
||||||
|
DIR=dist/dexarmarket
|
||||||
|
else
|
||||||
|
echo "no build output found under dist/dexarmarket" >&2
|
||||||
|
ls -R dist || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
test -f "$DIR/index.html" || { echo "$DIR has no index.html" >&2; exit 1; }
|
||||||
|
echo "dir=$DIR" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Deploying from $DIR ($(find "$DIR" -type f | wc -l) files)"
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
env:
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$DEPLOY_SSH_KEY" || { echo "secret DEPLOY_SSH_KEY is empty" >&2; exit 1; }
|
||||||
|
test -n "$DEPLOY_KNOWN_HOSTS" || { echo "secret DEPLOY_KNOWN_HOSTS is empty" >&2; exit 1; }
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
# Pinned host key, so a MITM or a rebuilt server fails the deploy
|
||||||
|
# instead of being trusted silently.
|
||||||
|
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Upload release
|
||||||
|
env:
|
||||||
|
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
SRC: ${{ steps.dist.outputs.dir }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RELEASE="${GITHUB_SHA::12}"
|
||||||
|
echo "RELEASE=$RELEASE" >> "$GITHUB_ENV"
|
||||||
|
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
|
||||||
|
$SSH "$USER@$HOST" "mkdir -p /srv/marketplaces/releases/$RELEASE/frontend"
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "$SSH" \
|
||||||
|
"$SRC/" "$USER@$HOST:/srv/marketplaces/releases/$RELEASE/frontend/"
|
||||||
|
|
||||||
|
- name: Activate release
|
||||||
|
env:
|
||||||
|
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" bash -euo pipefail <<EOSSH
|
||||||
|
BASE=/srv/marketplaces
|
||||||
|
REL="\$BASE/releases/$RELEASE"
|
||||||
|
test -f "\$REL/frontend/index.html" || { echo "upload incomplete, refusing to swap" >&2; exit 1; }
|
||||||
|
# ln -T onto a temp name then mv: the swap is atomic, so no request
|
||||||
|
# is ever served from a half-updated root.
|
||||||
|
ln -sfnT "\$REL" "\$BASE/current.new"
|
||||||
|
mv -Tf "\$BASE/current.new" "\$BASE/current"
|
||||||
|
sudo /bin/systemctl reload nginx
|
||||||
|
# Keep the last 5 releases so a rollback is a symlink change.
|
||||||
|
ls -1dt "\$BASE"/releases/*/ | tail -n +6 | xargs -r rm -rf
|
||||||
|
echo "active: \$(readlink -f \$BASE/current)"
|
||||||
|
EOSSH
|
||||||
|
|
||||||
|
- name: Verify
|
||||||
|
env:
|
||||||
|
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" \
|
||||||
|
'curl -fsS -o /dev/null -w "health=%{http_code}\n" http://127.0.0.1/health &&
|
||||||
|
curl -fsS -o /dev/null -w "index=%{http_code}\n" http://127.0.0.1/'
|
||||||
|
|
||||||
|
- name: Report
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ "${{ job.status }}" = "success" ]; then
|
||||||
|
echo "Deployed ${GITHUB_SHA::12} to ${{ secrets.DEPLOY_HOST }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "Deploy of ${GITHUB_SHA::12} FAILED. The previous release is still active — the symlink only moves after a successful upload." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
140
docs/DEPLOYMENT.md
Normal file
140
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# 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](backend/BACKEND-HANDOFF.md)). 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I http://<server-ip>/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `200`. A placeholder page is served until the first real deploy.
|
||||||
|
|
||||||
|
### 3.3 Capture the host key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
125
scripts/deploy/add-domain.sh
Executable file
125
scripts/deploy/add-domain.sh
Executable file
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Attach one customer domain to this server and issue a TLS certificate.
|
||||||
|
# Idempotent: re-running for an existing domain renews/repairs rather than duplicates.
|
||||||
|
# Run as root, AFTER the domain's A/AAAA record already resolves to this server.
|
||||||
|
#
|
||||||
|
# bash add-domain.sh shop.example.com --email ops@example.com
|
||||||
|
# bash add-domain.sh shop.example.com --email ops@example.com --with-www
|
||||||
|
#
|
||||||
|
# Why per-domain blocks exist at all: the application is multi-tenant off the
|
||||||
|
# Host header and needs no per-domain root. Certificates are the exception —
|
||||||
|
# certbot must match a concrete server_name, which `default_server _` is not.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="${1:-}"; shift || true
|
||||||
|
EMAIL=""
|
||||||
|
WITH_WWW=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--email) EMAIL="$2"; shift 2 ;;
|
||||||
|
--with-www) WITH_WWW=1; shift ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||||
|
[[ -n "$DOMAIN" ]] || { echo "usage: add-domain.sh <domain> --email <address> [--with-www]" >&2; exit 2; }
|
||||||
|
[[ -n "$EMAIL" ]] || { echo "--email is required (certbot expiry notices)" >&2; exit 2; }
|
||||||
|
|
||||||
|
# Fail loudly rather than let certbot fail obscurely on an unpointed domain.
|
||||||
|
echo "==> checking DNS for $DOMAIN"
|
||||||
|
RESOLVED="$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1 || true)"
|
||||||
|
if [[ -z "$RESOLVED" ]]; then
|
||||||
|
echo "ERROR: $DOMAIN does not resolve. Point its A record at this server first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " resolves to $RESOLVED"
|
||||||
|
|
||||||
|
NAMES="$DOMAIN"
|
||||||
|
CERT_ARGS=(-d "$DOMAIN")
|
||||||
|
if [[ $WITH_WWW -eq 1 ]]; then
|
||||||
|
NAMES="$DOMAIN www.$DOMAIN"
|
||||||
|
CERT_ARGS+=(-d "www.$DOMAIN")
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONF="/etc/nginx/sites-available/tenant-$DOMAIN.conf"
|
||||||
|
echo "==> nginx server block: $CONF"
|
||||||
|
cat > "$CONF" <<NGINX
|
||||||
|
# Tenant domain: $DOMAIN
|
||||||
|
# Same root as the catch-all — the SPA resolves the tenant from the Host header.
|
||||||
|
# This block exists so certbot has a concrete server_name to attach TLS to.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name $NAMES;
|
||||||
|
|
||||||
|
root /srv/marketplaces/current/frontend;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-store, must-revalidate" always;
|
||||||
|
try_files \$uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable" always;
|
||||||
|
try_files \$uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
ln -sfn "$CONF" "/etc/nginx/sites-enabled/tenant-$DOMAIN.conf"
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
echo "==> certificate"
|
||||||
|
# --nginx rewrites the block above in place to add listen 443 + ssl directives
|
||||||
|
# and an HTTP->HTTPS redirect. Re-running is a no-op when the cert is current.
|
||||||
|
certbot --nginx "${CERT_ARGS[@]}" \
|
||||||
|
--non-interactive --agree-tos --email "$EMAIL" \
|
||||||
|
--redirect --keep-until-expiring
|
||||||
|
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
echo "==> renewal timer"
|
||||||
|
systemctl enable --now certbot.timer
|
||||||
|
systemctl status certbot.timer --no-pager | head -3 || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "done. verify:"
|
||||||
|
echo " curl -I https://$DOMAIN/health"
|
||||||
|
echo " certbot certificates | grep -A3 $DOMAIN"
|
||||||
153
scripts/deploy/server-setup.sh
Executable file
153
scripts/deploy/server-setup.sh
Executable file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# One-time server provisioning for the marketplaces frontend.
|
||||||
|
# Idempotent: safe to re-run. Run as root on the target server.
|
||||||
|
#
|
||||||
|
# bash server-setup.sh --pubkey "ssh-ed25519 AAAA... ci@marketplaces"
|
||||||
|
#
|
||||||
|
# What it does NOT do: issue TLS certificates (no domain points here yet).
|
||||||
|
# Run add-domain.sh per domain once DNS resolves. See docs/DEPLOYMENT.md.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_USER="deploy"
|
||||||
|
BASE="/srv/marketplaces"
|
||||||
|
PUBKEY=""
|
||||||
|
KEEP_RELEASES=5
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--pubkey) PUBKEY="$2"; shift 2 ;;
|
||||||
|
--user) DEPLOY_USER="$2"; shift 2 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||||
|
[[ -n "$PUBKEY" ]] || { echo "--pubkey is required (the CI deploy key's PUBLIC half)" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "==> packages"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq nginx certbot python3-certbot-nginx rsync ufw
|
||||||
|
|
||||||
|
echo "==> deploy user: $DEPLOY_USER"
|
||||||
|
if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then
|
||||||
|
# No password is ever set: this account is key-only by construction.
|
||||||
|
adduser --system --group --shell /bin/bash --home "/home/$DEPLOY_USER" "$DEPLOY_USER"
|
||||||
|
fi
|
||||||
|
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"
|
||||||
|
AUTH="/home/$DEPLOY_USER/.ssh/authorized_keys"
|
||||||
|
touch "$AUTH"
|
||||||
|
grep -qxF "$PUBKEY" "$AUTH" || echo "$PUBKEY" >> "$AUTH"
|
||||||
|
chown "$DEPLOY_USER:$DEPLOY_USER" "$AUTH"
|
||||||
|
chmod 600 "$AUTH"
|
||||||
|
|
||||||
|
echo "==> directories"
|
||||||
|
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE" "$BASE/releases"
|
||||||
|
# First deploy creates $BASE/current as a symlink into releases/.
|
||||||
|
# Seed a placeholder so nginx starts cleanly before anything is deployed.
|
||||||
|
if [[ ! -e "$BASE/current" ]]; then
|
||||||
|
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE/releases/bootstrap/frontend"
|
||||||
|
echo "<!doctype html><title>marketplaces</title><p>Not deployed yet." \
|
||||||
|
> "$BASE/releases/bootstrap/frontend/index.html"
|
||||||
|
chown -R "$DEPLOY_USER:$DEPLOY_USER" "$BASE/releases/bootstrap"
|
||||||
|
ln -sfn "$BASE/releases/bootstrap" "$BASE/current"
|
||||||
|
chown -h "$DEPLOY_USER:$DEPLOY_USER" "$BASE/current"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> nginx catch-all (multi-tenant: one bundle serves every domain)"
|
||||||
|
cat > /etc/nginx/sites-available/marketplaces.conf <<'NGINX'
|
||||||
|
# Multi-tenant by design: the SPA derives its tenant from the Host header,
|
||||||
|
# so ONE server block serves every customer domain. Do not add a per-tenant
|
||||||
|
# root here. Per-domain server blocks exist only to hold TLS certificates
|
||||||
|
# (see add-domain.sh) and proxy to this same root.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv/marketplaces/current/frontend;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/marketplaces.access.log;
|
||||||
|
error_log /var/log/nginx/marketplaces.error.log;
|
||||||
|
|
||||||
|
# Do not let the browser cache the app shell: a deploy must take effect
|
||||||
|
# on the next reload, not whenever a stale index.html expires.
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-store, must-revalidate" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hashed build artifacts are immutable by construction.
|
||||||
|
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback. Must stay last: every unmatched path is a client route.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
ln -sfn /etc/nginx/sites-available/marketplaces.conf /etc/nginx/sites-enabled/marketplaces.conf
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
echo "==> firewall"
|
||||||
|
ufw allow OpenSSH >/dev/null
|
||||||
|
ufw allow 80/tcp >/dev/null
|
||||||
|
ufw allow 443/tcp >/dev/null
|
||||||
|
ufw --force enable >/dev/null
|
||||||
|
|
||||||
|
echo "==> nginx config test"
|
||||||
|
nginx -t
|
||||||
|
systemctl enable --now nginx
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
echo "==> sudoers: let the deploy user reload nginx, nothing else"
|
||||||
|
cat > /etc/sudoers.d/marketplaces-deploy <<SUDO
|
||||||
|
$DEPLOY_USER ALL=(root) NOPASSWD: /bin/systemctl reload nginx
|
||||||
|
SUDO
|
||||||
|
chmod 440 /etc/sudoers.d/marketplaces-deploy
|
||||||
|
visudo -c -f /etc/sudoers.d/marketplaces-deploy
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "done."
|
||||||
|
echo " deploy user : $DEPLOY_USER (key-only, no password)"
|
||||||
|
echo " web root : $BASE/current/frontend"
|
||||||
|
echo " keep : last $KEEP_RELEASES releases"
|
||||||
|
echo
|
||||||
|
echo "next:"
|
||||||
|
echo " 1. curl -I http://<this-server>/health -> expect 200"
|
||||||
|
echo " 2. point a domain's A record here"
|
||||||
|
echo " 3. bash add-domain.sh <domain> -> issues TLS"
|
||||||
|
echo " 4. add CI secrets, push to main -> first real deploy"
|
||||||
Reference in New Issue
Block a user