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>
126 lines
3.9 KiB
Bash
Executable File
126 lines
3.9 KiB
Bash
Executable File
#!/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"
|