ci: add frontend CD pipeline, server provisioning, TLS scripts
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:
sdarbinyan
2026-08-18 11:42:06 +04:00
parent 71da5a8d80
commit 28861953c8
5 changed files with 559 additions and 0 deletions

125
scripts/deploy/add-domain.sh Executable file
View 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
View 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"