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:
@@ -215,3 +215,5 @@ surface at `/rfc/<slug>` then has something real to render.
|
|||||||
The spec's decisions answer to it.
|
The spec's decisions answer to it.
|
||||||
- [`docs/DEV.md`](./docs/DEV.md) — the build's slicing plan, the
|
- [`docs/DEV.md`](./docs/DEV.md) — the build's slicing plan, the
|
||||||
current state, and the next slice's brief.
|
current state, and the next slice's brief.
|
||||||
|
- [`deploy/DEPLOY.md`](./deploy/DEPLOY.md) — single-host production
|
||||||
|
deployment behind nginx + Let's Encrypt.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user