From 33d9d7a4827cda0c9b66317685628e8782c507b7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 24 May 2026 05:18:28 -0700 Subject: [PATCH] =?UTF-8?q?Add=20deploy/=20=E2=80=94=20nginx=20vhost,=20sy?= =?UTF-8?q?stemd=20unit,=20runbook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-host deployment of the app at rfc.wiggleverse.org alongside the existing Gitea instance. nginx reverse-proxies /api/* and /auth/* to a single uvicorn process on 127.0.0.1:8000 and serves the Vite build output as static files; certbot adds the TLS cert in place; systemd supervises the process per §4.2's single-process-with-WAL-SQLite contract (one worker; raising --workers would break the invariant). deploy/DEPLOY.md is the step-by-step runbook covering host prep, Gitea bot + OAuth setup, .env shape, meta-repo seed, nginx + certbot, systemd, smoke test, and the update/rollback shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 + deploy/DEPLOY.md | 284 ++++++++++++++++++++++++++ deploy/nginx/rfc.wiggleverse.org.conf | 68 ++++++ deploy/systemd/rfc-app.service | 46 +++++ 4 files changed, 400 insertions(+) create mode 100644 deploy/DEPLOY.md create mode 100644 deploy/nginx/rfc.wiggleverse.org.conf create mode 100644 deploy/systemd/rfc-app.service diff --git a/README.md b/README.md index fe81636..d3921c7 100644 --- a/README.md +++ b/README.md @@ -215,3 +215,5 @@ surface at `/rfc/` then has something real to render. The spec's decisions answer to it. - [`docs/DEV.md`](./docs/DEV.md) — the build's slicing plan, the current state, and the next slice's brief. +- [`deploy/DEPLOY.md`](./deploy/DEPLOY.md) — single-host production + deployment behind nginx + Let's Encrypt. diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..ad7fb9e --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,284 @@ +# Deployment runbook + +Single-host deployment of the RFC app at `rfc.wiggleverse.org`, +sharing infrastructure with `git.wiggleverse.org` (same Gitea +instance, same nginx, same Let's Encrypt). The shape matches §4.2: +one process, one SQLite file, no separate worker. + +Bring-up order: host prep → Gitea side (bot, OAuth, meta repo) → app +side (code, venv, build, .env) → web server side (nginx, certbot) → +systemd → smoke-test. + +Every step is idempotent or no-op-on-rerun, so re-running this +runbook to recover from a partial install is safe. + +--- + +## Prereqs + +- Ubuntu/Debian-style host with nginx and certbot already serving + `git.wiggleverse.org` over HTTPS. +- DNS: an `A` record for `rfc.wiggleverse.org` pointing at the same + IP as `git.wiggleverse.org`. +- Python 3.11+ available system-wide. Node 20+ available (for + `npm run build` once; the build output is what runs in production — + Node isn't needed at runtime). + +--- + +## 1. Host prep + +Create the system user and the install directory. + +```sh +sudo useradd --system --shell /usr/sbin/nologin --home-dir /opt/rfc-app rfc-app +sudo mkdir -p /opt/rfc-app +sudo chown rfc-app:rfc-app /opt/rfc-app +``` + +Clone the repo. Use HTTPS since we don't need to push from the server. + +```sh +sudo -u rfc-app git clone https://git.wiggleverse.org/ben.stull/rfc-app.git /opt/rfc-app +``` + +--- + +## 2. Gitea side + +### 2.1 Create the bot service account + +In the Gitea web UI, signed in as a Gitea admin: + +- **Site Administration → User Accounts → Create User Account** +- Username: `rfc-bot` (or whatever you want) +- Email: anything sensible (e.g. `rfc-bot@wiggleverse.org`) +- Password: random, you won't use it interactively +- Send email confirmation: off + +Then sign in as the bot, open **Settings → Applications → Generate +New Token**, name it `rfc-app`, grant scopes: + +- `write:repository` +- `write:user` +- `write:admin` (needed because the bot creates per-RFC repos on + graduation — scope down to `repo`+`org` if you want to defer admin + until Slice 5) + +Copy the token. It goes into `.env` as `GITEA_BOT_TOKEN`. + +### 2.2 Create the org and add the bot + +The meta repo lives inside an org. In Gitea: **Create Organization → +wiggleverse**. Then **Members → Invite → rfc-bot → Owner**. + +### 2.3 Register the OAuth2 application + +**Site Administration → Integrations → OAuth2 Applications → +Create Application**: + +- Name: `RFC App` +- Redirect URI: `https://rfc.wiggleverse.org/auth/callback` + +Copy the client ID and client secret. They go into `.env`. + +--- + +## 3. App side + +### 3.1 Python venv + deps + +```sh +sudo -u rfc-app python3 -m venv /opt/rfc-app/backend/.venv +sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \ + -r /opt/rfc-app/backend/requirements.txt +``` + +### 3.2 Build the frontend + +Build locally if you don't want Node on the server; copy `dist/` +across: + +```sh +# On your laptop: +cd frontend && npm install && npm run build +rsync -a dist/ ben.stull@:/tmp/rfc-app-dist/ +# On the host: +sudo -u rfc-app mkdir -p /opt/rfc-app/frontend/dist +sudo cp -r /tmp/rfc-app-dist/. /opt/rfc-app/frontend/dist/ +sudo chown -R rfc-app:rfc-app /opt/rfc-app/frontend/dist +``` + +Or build on the host directly if Node is available: + +```sh +cd /opt/rfc-app/frontend && sudo -u rfc-app npm install +sudo -u rfc-app npm run build +``` + +### 3.3 Write `.env` + +```sh +sudo -u rfc-app cp /opt/rfc-app/backend/.env.example /opt/rfc-app/backend/.env +sudoedit /opt/rfc-app/backend/.env # set every value +``` + +Required values for production: + +```ini +GITEA_URL=https://git.wiggleverse.org +GITEA_BOT_USER=rfc-bot +GITEA_BOT_TOKEN= +GITEA_ORG=wiggleverse +META_REPO=meta + +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= + +APP_URL=https://rfc.wiggleverse.org +SECRET_KEY=<32+ random chars; generate with `openssl rand -hex 32`> +OWNER_GITEA_LOGIN=ben.stull +GITEA_WEBHOOK_SECRET= + +DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db + +# Optional — chat is disabled until at least one is set: +ENABLED_MODELS=claude +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= +OPENAI_API_KEY= +``` + +Lock the file down — it carries secrets: + +```sh +sudo chmod 600 /opt/rfc-app/backend/.env +sudo chown rfc-app:rfc-app /opt/rfc-app/backend/.env +``` + +### 3.4 Seed the meta repo + +This creates `wiggleverse/meta` on Gitea, populates the hand-authored +files, and registers the webhook against `APP_URL/api/webhooks/gitea`. + +```sh +sudo -u rfc-app -H bash -c \ + 'cd /opt/rfc-app/backend && .venv/bin/python ../scripts/seed_meta_repo.py' +``` + +Re-running is safe; every step is upsert-shaped. + +--- + +## 4. Web server side + +### 4.1 nginx vhost + +```sh +sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \ + /etc/nginx/sites-available/rfc.wiggleverse.org +sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ + /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +Make sure the nginx user can read `/opt/rfc-app/frontend/dist`. The +simplest path: add the nginx user (usually `www-data`) to the +`rfc-app` group and chmod the dist tree group-readable: + +```sh +sudo usermod -a -G rfc-app www-data +sudo chmod -R g+rX /opt/rfc-app/frontend/dist +sudo systemctl reload nginx +``` + +### 4.2 Let's Encrypt cert + +```sh +sudo certbot --nginx -d rfc.wiggleverse.org +``` + +Certbot rewrites the vhost in place to add the 443 listener and +certificate directives. After it finishes, `https://rfc.wiggleverse.org` +serves but the backend isn't up yet — the next step starts it. + +--- + +## 5. systemd + +```sh +sudo cp /opt/rfc-app/deploy/systemd/rfc-app.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now rfc-app +sudo systemctl status rfc-app +``` + +Watch the logs: + +```sh +sudo journalctl -u rfc-app -f +``` + +Expected startup line: `RFC app started — meta repo wiggleverse/meta`. + +--- + +## 6. Smoke test + +In a browser at `https://rfc.wiggleverse.org`: + +1. The landing page renders. (Slice 1 placeholder; Slice 7 polishes.) +2. Click **Sign in with Gitea** → OAuth round-trip → you land on the + catalog. (Empty on first visit — no RFCs yet.) +3. Click **+ Propose New RFC**, fill in title/slug/pitch, submit. + The pending-ideas disclosure shows the new PR. +4. Click the PR row, then **Merge proposal**. The catalog refreshes + with the super-draft. +5. Optional: seed an active RFC for §8 testing (see + [README.md](../README.md#seeding-an-active-rfc-for-8-testing)). + +If anything misfires, the troubleshooting section below covers the +common failure modes. + +--- + +## 7. Updating after a push + +```sh +sudo -u rfc-app git -C /opt/rfc-app pull +sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \ + -r /opt/rfc-app/backend/requirements.txt +# Rebuild the frontend locally and rsync dist/ as in step 3.2. +sudo systemctl restart rfc-app +``` + +The §5 schema migrations run on startup and are append-only, so a +restart is the entire deploy. No reverse-migration story for now — +add one when the schema starts breaking back-compat (deferred until +Slice 8 hardening). + +--- + +## Troubleshooting + +- **`systemctl status rfc-app` shows `RuntimeError: Required + environment variable ... is not set`.** The `.env` is missing a + value, or `EnvironmentFile=` in the systemd unit isn't finding it. + Confirm `/opt/rfc-app/backend/.env` exists and is mode 0600 owned + by `rfc-app`. +- **OAuth callback returns "Invalid state".** The redirect URI in + Gitea must match `APP_URL/auth/callback` exactly. Confirm it's + `https://rfc.wiggleverse.org/auth/callback`. +- **The catalog stays empty after a merge.** Check the webhook: + `journalctl -u rfc-app | grep webhook`. Gitea's + **Settings → Webhooks → Recent Deliveries** on the meta repo shows + the delivery status; the reconciler will catch up within 5 minutes + anyway. +- **`502 Bad Gateway` on /api/* or /auth/*.** uvicorn isn't running + or isn't bound to `127.0.0.1:8000`. `systemctl status rfc-app`. +- **`403` from nginx on static assets.** The nginx user can't read + `/opt/rfc-app/frontend/dist`. Apply the chmod from step 4.1. +- **OAuth works, but the user can't propose.** The `users` row was + created with role `contributor`; only `OWNER_GITEA_LOGIN`'s login + gets `owner` on first sign-in. Confirm `.env` has the right value + and you signed in with that account. diff --git a/deploy/nginx/rfc.wiggleverse.org.conf b/deploy/nginx/rfc.wiggleverse.org.conf new file mode 100644 index 0000000..e83b6d8 --- /dev/null +++ b/deploy/nginx/rfc.wiggleverse.org.conf @@ -0,0 +1,68 @@ +# nginx vhost for the RFC app — single-process FastAPI behind nginx, +# frontend served as static files from the Vite build output. +# +# Install: +# sudo cp deploy/nginx/rfc.wiggleverse.org.conf \ +# /etc/nginx/sites-available/rfc.wiggleverse.org +# sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ +# /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx +# +# Then add the Let's Encrypt cert: +# sudo certbot --nginx -d rfc.wiggleverse.org +# Certbot will rewrite this file to add the 443 listener and certificate +# directives; the rest of the config below stays as written. + +server { + listen 80; + listen [::]:80; + server_name rfc.wiggleverse.org; + + # Static SPA assets live in the Vite build output. The systemd unit + # runs as user `rfc-app`; make sure nginx (usually `www-data`) can + # read this path. Either group-add www-data into rfc-app's group, or + # chmod o+r on the dist/ tree. + root /opt/rfc-app/frontend/dist; + index index.html; + + # API routes are proxied to the FastAPI process. SSE chat streams + # need proxy_buffering off so chunks reach the browser immediately; + # the long read_timeout matches a slow LLM turn. + location /api/ { + proxy_pass http://127.0.0.1:8000; + 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_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + } + + location /auth/ { + proxy_pass http://127.0.0.1:8000; + 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; + } + + # SPA fallback — any non-asset path falls back to index.html so + # React Router can take over. + location / { + try_files $uri $uri/ /index.html; + } + + # Cache the hashed JS/CSS bundles aggressively; Vite includes a + # content-hash in the filename so updates bust the cache for free. + location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Reasonable upload cap. Adjust if RFC bodies grow large. + client_max_body_size 4M; +} diff --git a/deploy/systemd/rfc-app.service b/deploy/systemd/rfc-app.service new file mode 100644 index 0000000..69c9b72 --- /dev/null +++ b/deploy/systemd/rfc-app.service @@ -0,0 +1,46 @@ +# systemd unit for the FastAPI process. +# +# Install: +# sudo cp deploy/systemd/rfc-app.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now rfc-app +# sudo systemctl status rfc-app +# +# Logs: +# sudo journalctl -u rfc-app -f +# +# Per §4.2 the app is intentionally single-process — one uvicorn +# worker, colocated SQLite. If the deployment ever needs more than one +# worker, the spec calls for a planned migration to Postgres first; +# raising `--workers` here would break the WAL-mode SQLite invariant. + +[Unit] +Description=Wiggleverse RFC app +After=network.target +Documentation=https://git.wiggleverse.org/ben.stull/rfc-app + +[Service] +Type=simple +User=rfc-app +Group=rfc-app +WorkingDirectory=/opt/rfc-app/backend +EnvironmentFile=/opt/rfc-app/backend/.env +ExecStart=/opt/rfc-app/backend/.venv/bin/uvicorn app.main:app \ + --host 127.0.0.1 \ + --port 8000 \ + --proxy-headers \ + --forwarded-allow-ips 127.0.0.1 +Restart=on-failure +RestartSec=5s + +# Hardening — modest defaults; tighten further if the host runs other +# services. The bot wrapper writes only to its own data dir; everything +# else is read-only. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/opt/rfc-app/backend/data + +[Install] +WantedBy=multi-user.target