From 3fdd2ff0aa413d8f3bea1865f18952ecb603d549 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 19:06:42 +0000 Subject: [PATCH] feat(deploy): VPS provisioning assets for apps/api (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds infra-as-reviewed-text for the gatherloop-api VPS: an idempotent provision.sh (users, filesystem layout, systemd unit, sudoers grant, env template, ufw, Caddy), the systemd unit, sudoers drop-in, Caddyfile and env file templates, and the operator runbook (provisioning, secret rotation, logs, manual rollback, and the manual migration/seed procedures). No CI deploy wiring and no production traffic change yet — see docs/trd-vps-deployment-automation.md Phase 3. --- deploy/vps/caddy/Caddyfile.example | 11 + deploy/vps/env/api.env.example | 29 ++ deploy/vps/provision.sh | 161 +++++++++++ deploy/vps/sudoers/gatherloop-deploy | 14 + deploy/vps/systemd/gatherloop-api.service | 31 +++ docs/runbook-vps-deployment.md | 320 ++++++++++++++++++++++ 6 files changed, 566 insertions(+) create mode 100644 deploy/vps/caddy/Caddyfile.example create mode 100644 deploy/vps/env/api.env.example create mode 100755 deploy/vps/provision.sh create mode 100644 deploy/vps/sudoers/gatherloop-deploy create mode 100644 deploy/vps/systemd/gatherloop-api.service create mode 100644 docs/runbook-vps-deployment.md diff --git a/deploy/vps/caddy/Caddyfile.example b/deploy/vps/caddy/Caddyfile.example new file mode 100644 index 00000000..668d5d79 --- /dev/null +++ b/deploy/vps/caddy/Caddyfile.example @@ -0,0 +1,11 @@ +# /etc/caddy/Caddyfile +# +# Copy this file to /etc/caddy/Caddyfile on the VPS and replace the +# placeholder hostname with the real API domain before starting Caddy. +# Caddy obtains and renews the Let's Encrypt certificate automatically — +# no certbot, no renewal cron. + +api.gatherloop.example { + encode zstd gzip + reverse_proxy 127.0.0.1:8000 +} diff --git a/deploy/vps/env/api.env.example b/deploy/vps/env/api.env.example new file mode 100644 index 00000000..2502096d --- /dev/null +++ b/deploy/vps/env/api.env.example @@ -0,0 +1,29 @@ +# /etc/gatherloop-api/api.env +# +# The single environment file for the gatherloop-api systemd service. +# Installed by provision.sh at /etc/gatherloop-api/api.env with mode 0640, +# owner root:gatherloop — readable only by root and the gatherloop group +# (i.e. the gatherloop service user). The deploy user cannot read it. +# +# Copy this file to /etc/gatherloop-api/api.env on the VPS and fill in real +# values. Never commit real values, never put them in the release tarball. + +# --- Database (existing managed MySQL, see TRD Decision 5) --- +DB_USERNAME= +DB_PASSWORD= +DB_NAME= +DB_HOST= +DB_PORT=3306 + +# --- HTTP server --- +# Caddy is the only thing that should reach the API; bind loopback-only. +PORT=8000 +BIND_ADDR=127.0.0.1 + +# --- Auth --- +JWT_SECRET= + +# --- Logging --- +LOG_LEVEL=info # debug | info | warn | error +APP_ENV=production +SERVICE_NAME=gatherloop-pos-api diff --git a/deploy/vps/provision.sh b/deploy/vps/provision.sh new file mode 100755 index 00000000..c678c021 --- /dev/null +++ b/deploy/vps/provision.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# provision.sh — idempotent, re-runnable setup for the gatherloop-api VPS. +# +# Creates the two Unix users, the release directory layout, the systemd +# unit, the sudoers drop-in, the env file template, the firewall rules, +# and Caddy. Safe to run again after editing any file in this directory — +# existing secrets and an already-customised Caddyfile are never +# overwritten. See docs/runbook-vps-deployment.md for first-time setup. +# +# Usage: sudo ./provision.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +APP_USER="${APP_USER:-gatherloop}" +DEPLOY_USER="${DEPLOY_USER:-deploy}" +APP_DIR="${APP_DIR:-/opt/gatherloop-api}" +CONFIG_DIR="${CONFIG_DIR:-/etc/gatherloop-api}" +SERVICE_NAME="${SERVICE_NAME:-gatherloop-api}" +SSH_PORT="${SSH_PORT:-22}" + +log() { + printf '[provision] %s\n' "$*" +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + echo "must be run as root (try: sudo $0)" >&2 + exit 1 + fi +} + +ensure_app_user() { + if ! getent group "$APP_USER" >/dev/null; then + groupadd --system "$APP_USER" + log "created group $APP_USER" + fi + + if ! id -u "$APP_USER" >/dev/null 2>&1; then + useradd --system --gid "$APP_USER" --no-create-home \ + --shell /usr/sbin/nologin --comment "Gatherloop API service user" "$APP_USER" + log "created user $APP_USER (no-login, no home)" + else + log "user $APP_USER already exists, leaving untouched" + fi +} + +ensure_deploy_user() { + if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then + useradd --system --create-home --home-dir "/home/$DEPLOY_USER" \ + --shell /bin/bash --comment "CI deploy user" "$DEPLOY_USER" + passwd -l "$DEPLOY_USER" >/dev/null + log "created user $DEPLOY_USER (password login locked, SSH key only)" + else + log "user $DEPLOY_USER already exists, leaving untouched" + fi + + install -d -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 0700 "/home/$DEPLOY_USER/.ssh" + if [[ ! -e "/home/$DEPLOY_USER/.ssh/authorized_keys" ]]; then + install -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 0600 /dev/null \ + "/home/$DEPLOY_USER/.ssh/authorized_keys" + log "created empty /home/$DEPLOY_USER/.ssh/authorized_keys — add CI's public key" + fi +} + +ensure_filesystem_layout() { + install -d -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 0755 "$APP_DIR" + install -d -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 0755 "$APP_DIR/releases" + log "release directory layout ready at $APP_DIR" + + install -d -o root -g "$APP_USER" -m 0750 "$CONFIG_DIR" + + if [[ ! -e "$CONFIG_DIR/api.env" ]]; then + install -o root -g "$APP_USER" -m 0640 "$SCRIPT_DIR/env/api.env.example" "$CONFIG_DIR/api.env" + log "installed $CONFIG_DIR/api.env from template — fill in real secrets, then restart the service" + else + log "$CONFIG_DIR/api.env already exists, leaving untouched" + fi +} + +install_systemd_unit() { + install -o root -g root -m 0644 \ + "$SCRIPT_DIR/systemd/gatherloop-api.service" "/etc/systemd/system/${SERVICE_NAME}.service" + systemctl daemon-reload + systemctl enable "${SERVICE_NAME}.service" + log "installed and enabled ${SERVICE_NAME}.service (inactive until a release is deployed)" +} + +install_sudoers() { + local tmp + tmp="$(mktemp)" + install -m 0440 "$SCRIPT_DIR/sudoers/gatherloop-deploy" "$tmp" + + if ! visudo -c -f "$tmp" >/dev/null; then + echo "generated sudoers file failed validation, aborting" >&2 + rm -f "$tmp" + exit 1 + fi + + install -o root -g root -m 0440 "$tmp" /etc/sudoers.d/gatherloop-deploy + rm -f "$tmp" + log "installed /etc/sudoers.d/gatherloop-deploy" +} + +configure_firewall() { + if ! command -v ufw >/dev/null 2>&1; then + apt-get update -y + apt-get install -y ufw + log "installed ufw" + fi + + ufw default deny incoming + ufw default allow outgoing + ufw allow "${SSH_PORT}/tcp" + ufw allow 80/tcp + ufw allow 443/tcp + ufw --force enable + log "ufw active: deny inbound except ${SSH_PORT}/tcp, 80/tcp, 443/tcp" +} + +install_caddy() { + if ! command -v caddy >/dev/null 2>&1; then + apt-get update -y + apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + -o /etc/apt/sources.list.d/caddy-stable.list + apt-get update -y + apt-get install -y caddy + log "installed caddy" + else + log "caddy already installed" + fi + + if [[ ! -e /etc/caddy/Caddyfile ]]; then + install -o root -g root -m 0644 "$SCRIPT_DIR/caddy/Caddyfile.example" /etc/caddy/Caddyfile + log "installed /etc/caddy/Caddyfile from template — replace the placeholder hostname before pointing DNS at this host" + else + log "/etc/caddy/Caddyfile already exists, leaving untouched" + fi + + systemctl enable caddy + systemctl restart caddy + log "caddy enabled and (re)started" +} + +main() { + require_root + ensure_app_user + ensure_deploy_user + ensure_filesystem_layout + install_systemd_unit + install_sudoers + configure_firewall + install_caddy + log "provisioning complete" +} + +main "$@" diff --git a/deploy/vps/sudoers/gatherloop-deploy b/deploy/vps/sudoers/gatherloop-deploy new file mode 100644 index 00000000..a2caa6df --- /dev/null +++ b/deploy/vps/sudoers/gatherloop-deploy @@ -0,0 +1,14 @@ +# /etc/sudoers.d/gatherloop-deploy +# +# Grants the `deploy` user exactly two privileged commands, both NOPASSWD. +# `deploy` owns /opt/gatherloop-api and reads no application secrets — see +# docs/runbook-vps-deployment.md and the TRD for the security rationale. +# +# Install with mode 0440, owner root:root, and always validate with +# `visudo -c -f /etc/sudoers.d/gatherloop-deploy` before and after editing — +# a broken sudoers file can lock out sudo entirely. + +Cmnd_Alias GATHERLOOP_RELEASE = /usr/local/bin/gatherloop-release * +Cmnd_Alias GATHERLOOP_ROLLBACK = /usr/local/bin/gatherloop-rollback + +deploy ALL=(root) NOPASSWD: GATHERLOOP_RELEASE, GATHERLOOP_ROLLBACK diff --git a/deploy/vps/systemd/gatherloop-api.service b/deploy/vps/systemd/gatherloop-api.service new file mode 100644 index 00000000..1c5834d4 --- /dev/null +++ b/deploy/vps/systemd/gatherloop-api.service @@ -0,0 +1,31 @@ +[Unit] +Description=Gatherloop POS API +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=gatherloop +Group=gatherloop +WorkingDirectory=/opt/gatherloop-api/current +EnvironmentFile=/etc/gatherloop-api/api.env +ExecStart=/opt/gatherloop-api/current/api +Restart=on-failure +RestartSec=2s +KillSignal=SIGTERM +TimeoutStopSec=35s + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_INET AF_INET6 +LockPersonality=true +MemoryDenyWriteExecute=true +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target diff --git a/docs/runbook-vps-deployment.md b/docs/runbook-vps-deployment.md new file mode 100644 index 00000000..f01d5050 --- /dev/null +++ b/docs/runbook-vps-deployment.md @@ -0,0 +1,320 @@ +# Runbook: `apps/api` VPS Deployment + +Operational reference for the VPS that runs `apps/api` in production. Pairs +with [`docs/trd-vps-deployment-automation.md`](./trd-vps-deployment-automation.md), +which explains *why* the system is shaped this way. This document is the +*how*. + +> **Status of this runbook**: provisioning (this document, `deploy/vps/provision.sh`) +> lands in Phase 3 of the TRD. The release script (`gatherloop-release`) and +> the rollback script (`gatherloop-rollback`) referenced below are Phase 4 +> deliverables. Until Phase 4 ships, "rollback" means the manual symlink +> procedure in [Manual rollback](#manual-rollback), and deploys are a manual +> `scp` + `sudo gatherloop-release` rather than a GitHub Actions run. + +--- + +## 1. Architecture + +``` + Internet ──443──> Caddy (auto-TLS) ──> 127.0.0.1:8000 ──> gatherloop-api + (systemd) + │ + existing managed MySQL +``` + +Filesystem layout on the VPS: + +``` +/opt/gatherloop-api/ +├── releases/ +│ ├── 20260728T101500Z-a1b2c3d/ # immutable, one dir per deploy +│ │ ├── api +│ │ ├── migrate +│ │ ├── seed +│ │ └── RELEASE # sha, ref, built_at, run_id, go version +│ └── 20260727T183000Z-9f8e7d6/ +├── current -> releases/20260728T101500Z-a1b2c3d +└── previous -> releases/20260727T183000Z-9f8e7d6 + +/etc/gatherloop-api/ +└── api.env 0640 root:gatherloop # the only env file; deploy cannot read it + +/usr/local/bin/gatherloop-release 0755 root:root # Phase 4 +/usr/local/bin/gatherloop-rollback 0755 root:root # Phase 4 +/etc/systemd/system/gatherloop-api.service +/etc/sudoers.d/gatherloop-deploy +/etc/caddy/Caddyfile +``` + +Two Unix users, neither of which can do the other's job: + +| User | Role | Can read secrets? | Privileges | +|---|---|---|---| +| `gatherloop` | Runs the API process. System user, `nologin`, owns nothing writable. | Yes — the only user that can read `/etc/gatherloop-api/api.env`. | None. | +| `deploy` | SSH target for CI (and for a human doing a manual deploy). Owns `/opt/gatherloop-api`. | No. | Exactly two `sudo` grants: `gatherloop-release`, `gatherloop-rollback`. | + +A compromised `deploy` key gets code execution as an unprivileged user with +no database credentials in reach — see the TRD's Security Considerations for +the full rationale. + +--- + +## 2. First-time provisioning + +Prerequisites: a fresh Ubuntu/Debian VPS, root (or root-equivalent sudo) +access over SSH, and this repository checked out on the box (or copied over) +so `deploy/vps/` is available locally. + +```bash +# on the VPS, as root +git clone /tmp/gatherloop-pos # or scp the deploy/vps directory over +cd /tmp/gatherloop-pos +sudo ./deploy/vps/provision.sh +``` + +What it does, in order (see the script for the authoritative list): + +1. Creates the `gatherloop` system user/group (`nologin`, no home) — the + runtime identity for the API process. +2. Creates the `deploy` user (password login locked, SSH-key only) and an + empty `~/.ssh/authorized_keys` for it if one doesn't exist yet. +3. Creates `/opt/gatherloop-api/{,releases}` owned by `deploy`. +4. Creates `/etc/gatherloop-api` (`0750 root:gatherloop`) and installs + `api.env` from the template **only if one isn't already there** — it will + never overwrite a real secrets file on a re-run. +5. Installs and enables (but does not start) the `gatherloop-api.service` + systemd unit. It stays `inactive` until the first release exists — there + is nothing at `/opt/gatherloop-api/current/api` yet. +6. Installs `/etc/sudoers.d/gatherloop-deploy`, validating it with + `visudo -c` before it's put in place. +7. Configures `ufw`: default-deny inbound, allow SSH / 80 / 443. +8. Installs Caddy (official apt repo) and installs `/etc/caddy/Caddyfile` + from the template **only if one isn't already there**. + +The script is idempotent — re-run it any time a file under `deploy/vps/` +changes in the repo. **Never hand-edit a file on the VPS that provision.sh +manages; change the repo and re-run the script instead**, or the VPS drifts +from what's reviewed in source control. + +### Required manual steps after the first run + +These are deliberately *not* automated — they're one-time, host-specific, +and touch real secrets or DNS: + +1. **SSH key**: append CI's public key (the pair for `VPS_SSH_PRIVATE_KEY`) + to `/home/deploy/.ssh/authorized_keys`. +2. **Secrets**: edit `/etc/gatherloop-api/api.env` and fill in `DB_USERNAME`, + `DB_PASSWORD`, `DB_NAME`, `DB_HOST`, `DB_PORT`, `JWT_SECRET`. Leave + `BIND_ADDR=127.0.0.1` and `PORT=8000` as shipped — Caddy expects the + backend on that address. +3. **Domain**: edit `/etc/caddy/Caddyfile`, replacing the placeholder + hostname with the real API domain, then `sudo systemctl reload caddy`. + Don't do this until DNS for that host actually points here, or + certificate issuance will fail and retry. + +### Verifying provisioning + +```bash +systemctl status gatherloop-api # loaded, enabled, inactive (dead) +systemd-analyze verify /etc/systemd/system/gatherloop-api.service +sudo -l -U deploy # exactly the two NOPASSWD grants +sudo ufw status verbose +systemctl status caddy +``` + +> Note: `systemd-analyze verify` reports `Command .../current/api is not +> executable: No such file or directory` until the first release is +> deployed — expected, since `current` doesn't exist yet. It stops +> reporting that once the first deploy creates the symlink. + +--- + +## 3. Secret rotation + +Secrets live in exactly one place: `/etc/gatherloop-api/api.env` +(`0640 root:gatherloop`). Nothing in CI, in the release tarball, or in the +`deploy` user's reach can read it. + +To rotate a secret (e.g. `JWT_SECRET`, a DB password): + +```bash +sudo -e /etc/gatherloop-api/api.env # or: sudoedit +sudo systemctl restart gatherloop-api +curl -s http://127.0.0.1:8000/health-check | jq . +``` + +Rotating the DB password additionally requires updating it on the managed +MySQL side first (or in lockstep) — the API will fail to connect and the +service will crash-loop (`Restart=on-failure`) until both sides agree. + +--- + +## 4. Logs and observability + +The app logs structured JSON to stdout, which systemd sends to journald. + +```bash +journalctl -u gatherloop-api -f # tail +journalctl -u gatherloop-api -p err --since -1h # errors in the last hour +systemctl status gatherloop-api # unit + recent log lines +cat /opt/gatherloop-api/current/RELEASE # what is actually running +curl -s http://127.0.0.1:8000/health-check | jq . # status, version, commit, uptime +``` + +`/health-check` is the single "what is deployed" oracle — checkable from +anywhere over HTTPS once Caddy is fronting it, no SSH required. + +--- + +## 5. Manual rollback + +Once Phase 4 ships, `sudo gatherloop-rollback` does this atomically and +health-gates the result. Until then (or if you need to bypass it), the +underlying procedure is a symlink repoint: + +```bash +# on the VPS +cd /opt/gatherloop-api +ls -la releases/ # confirm the target release exists +sudo -u deploy ln -sfn "$(readlink current)" /tmp/rollback-prev # note current, for reference +sudo -u deploy ln -sfn "releases/" /tmp/current-new +sudo -u deploy mv -T /tmp/current-new current +sudo systemctl restart gatherloop-api +curl -s http://127.0.0.1:8000/health-check | jq . # confirm version matches the rolled-back release +``` + +Swap `previous` to point at the release you just moved away from if you +want a second rollback to be symmetric. Prefer `gatherloop-rollback` once +it exists — it does exactly this, plus the health gate, in one command. + +--- + +## 6. Database migrations (manual, by design) + +**Migrations are never run by the deploy pipeline.** The deploy user holds +no database credentials — see the TRD's Decisions #9 and #10. An operator +runs migrations by hand, on the VPS, using the version-matched `migrate` +binary that ships in every release. + +### The ordering rule + +> **Apply the migration before deploying the code that depends on it.** + +Deploying code that expects a column that doesn't exist yet fails the +release's health check and auto-rolls back — a loud, safe failure, but an +avoidable one. For a destructive schema change, invert the order instead: +ship the code that stops using the column *first*, then drop the column in +a later, separate migration (expand → deploy → contract). That ordering +also keeps automatic rollback safe, since rolling the code back one version +never lands it against a schema it can no longer read. + +### Applying pending migrations + +`cmd/migrate/main.go` always applies **all pending `up` migrations** and +logs the resulting schema version — it takes no arguments and has no +interactive mode. Run it via `systemd-run` as the `gatherloop` user so the +DB credentials come from the same env file the service uses, and are never +typed into a shell or left in shell history: + +```bash +# on the VPS, as an operator with sudo +sudo systemd-run --pipe --wait --property=User=gatherloop \ + --property=EnvironmentFile=/etc/gatherloop-api/api.env \ + /opt/gatherloop-api/current/migrate +``` + +Watch the output — it logs `migrations applied` with the resulting +`version` and `dirty` flag, or panics with the underlying error if a +migration fails partway (in which case `dirty=true`; see below). + +### Seeding reference data + +Same pattern, same binary contract (`cmd/seed/main.go`), just as deliberate: + +```bash +sudo systemd-run --pipe --wait --property=User=gatherloop \ + --property=EnvironmentFile=/etc/gatherloop-api/api.env \ + /opt/gatherloop-api/current/seed +``` + +### Checking the current schema version + +The shipped `migrate` binary only moves forward (`Up()`); it has no +"report version and exit" mode. `golang-migrate` tracks its state in a +`schema_migrations` table, so query it directly with a MySQL client using +the same credentials as `api.env`: + +```bash +mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_NAME" \ + -e "SELECT version, dirty FROM schema_migrations;" +``` + +(Source the values from `/etc/gatherloop-api/api.env` rather than typing +the password on the command line where a shell might log it — e.g. +`set -a; source /etc/gatherloop-api/api.env; set +a` in a root shell first, +or use a `~/.my.cnf` with restrictive permissions.) + +`version` is the sequence number of the last migration file applied +(matching the numeric prefix in `apps/api/migrations/`, e.g. `12` for +`000012_*.up.sql`). `dirty=1` means a previous migration attempt failed +partway through and needs manual intervention before anything else will +run — `golang-migrate` refuses to proceed while dirty. + +### Rolling back a migration by hand + +There is no automated `down` path shipped to the VPS — reversing a +migration is a deliberate, manual act: + +1. Find the corresponding `NNN_.down.sql` file for the version you're + reversing in `apps/api/migrations/` (in your local checkout — nothing + needs to be copied to the VPS for this, you're reading it to know what + SQL to run). +2. Connect to the database and execute that file's SQL by hand: + ```bash + mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_NAME" \ + < NNN_.down.sql + ``` +3. Update `schema_migrations` to reflect the reversed version: + ```sql + UPDATE schema_migrations SET version = , dirty = 0; + ``` +4. Re-run the "checking the current schema version" query to confirm. + +If a migration failed partway (`dirty=1`) rather than being deliberately +reversed, inspect what the `up.sql` actually changed before touching +`schema_migrations` by hand — the right fix depends on how far the +statement got, and may mean finishing the change manually rather than +reversing it. + +--- + +## 7. Firewall and TLS + +`ufw` denies all inbound traffic except SSH, 80, and 443 — port 8000 (the +API's actual listen port) is unreachable from outside the box even if +`BIND_ADDR` were misconfigured. Caddy terminates TLS and proxies to +`127.0.0.1:8000`, obtaining and renewing the Let's Encrypt certificate +automatically. There is no certbot, no renewal cron, and no manual +certificate handling. + +```bash +sudo ufw status verbose +sudo systemctl status caddy +sudo journalctl -u caddy -f # certificate issuance / renewal logs +``` + +--- + +## 8. Recording the VPS architecture + +- OS/arch: record `uname -m` here once the VPS exists — the CI build's + `GOARCH` (see the TRD's Detailed Design) must match it. A mismatch + produces `Exec format error` at restart, which the health gate catches + and auto-rolls back, but it's better to get right the first time. +- SSH port, hostname/IP: whatever was used for the `VPS_HOST` / `VPS_PORT` + GitHub Secrets (Phase 5). +- Caddy domain: whatever replaced the placeholder in `/etc/caddy/Caddyfile`. + +(Fill these in for the real box once it's provisioned; kept out of this +runbook's checked-in copy since they're host-specific, not repo-specific.)