Add deploy/ — nginx vhost, systemd unit, runbook

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) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 05:18:28 -07:00
parent c82328e9ad
commit 33d9d7a482
4 changed files with 400 additions and 0 deletions
+2
View File
@@ -215,3 +215,5 @@ surface at `/rfc/<slug>` 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.
+284
View File
@@ -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@<host>:/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=<the token from step 2.1>
GITEA_ORG=wiggleverse
META_REPO=meta
OAUTH_CLIENT_ID=<from step 2.3>
OAUTH_CLIENT_SECRET=<from step 2.3>
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=<random; generate with `openssl rand -hex 32`>
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.
+68
View File
@@ -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;
}
+46
View File
@@ -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