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:
@@ -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