Infrastructure
Deployment
Production runs on Railway as separate services built from Dockerfiles in this repository. Railway terminates HTTPS, so none of the containers deal with certificates.
The services
| Service | Dockerfile | Config | What it is |
|---|---|---|---|
| Backend | Dockerfile.backend.railway |
deploy/railway.backend.toml |
Fastify API + SQLite on a volume |
| Frontend | Dockerfile.frontend.railway |
deploy/railway.frontend.toml |
Built React SPA served by Caddy |
| Docs | Dockerfile.docs.railway |
deploy/railway.docs.toml |
This handbook, served by Caddy |
Each is created in the Railway dashboard from the same repository, with its
Config-as-code path pointed at the matching .toml.
Backend service
Volume
The SQLite database lives on a Railway volume mounted at /data, with
DATABASE_PATH=/data/app.db. Without the volume the database is wiped on every deploy —
this is the single most important thing to get right.
Railway mounts the volume root-owned. deploy/backend-entrypoint.sh runs as root,
takes ownership of /data, then drops to the unprivileged node user via gosu before
starting the app.
Environment variables
Set these in the Railway service's Variables tab:
| Variable | Value |
|---|---|
DATABASE_PATH |
/data/app.db |
JWT_SECRET |
A long random string. Changing it logs every student out |
ADMIN_TOKEN |
A long random string. This is the admin password |
CORS_ORIGIN |
The frontend's public URL, e.g. https://your-frontend.up.railway.app |
YOUTUBE_API_KEY |
From Google Cloud Console |
RESEND_API_KEY |
From resend.com — required for password-reset and new-question emails |
EMAIL_FROM |
e.g. Expert Vote <noreply@yourdomain> |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
Generate with npx web-push generate-vapid-keys — required for browser push notifications |
VAPID_SUBJECT |
A mailto: contact address, e.g. mailto:admin@yourdomain |
PORT is injected by Railway; do not set it. LOG_LEVEL defaults to info.
Warning:
CORS_ORIGINdoes double duty — it is also the base URL used to build the links in outgoing email, both password resets and new-question announcements. If it is wrong, those emails point students at the wrong host and the links are dead.
Health check and tests
The service exposes /health returning {"status":"ok"}; Railway gates traffic on it.
The backend Docker build runs the test suite before compiling. A failing test aborts the build and Railway marks the deploy failed. This is intentional: it is the only automated gate between a bad commit and production.
Frontend service
The frontend is a static build. The one thing to know:
Danger:
VITE_API_URLis baked in at build time. Vite inlines environment variables during the build, so setting it after deploying does nothing — you must set it as a service variable before the build and redeploy to change it.
Set it to the backend's public URL. Caddy serves the built files with SPA fallback so
deep links and refreshes work, and sends Cache-Control: no-cache for index.html,
sw.js and manifest.json so students always get the current version.
First deploy, in order
The two services reference each other's URLs, so ordering matters.
- Create the backend service. Point Config-as-code at
deploy/railway.backend.toml. Attach a volume mounted at/data. Set every variable exceptCORS_ORIGIN. Deploy. - Note the backend's public URL.
- Create the frontend service. Point it at
deploy/railway.frontend.toml. SetVITE_API_URLto the backend URL. Deploy. - Note the frontend's public URL.
- Set
CORS_ORIGINon the backend to the frontend URL. Redeploy the backend. - Set up the cron jobs — see Cron setup.
- Verify: register a test account, vote, resolve from the admin console.
The docs site itself
This handbook is a third static service, built by a small Node script — no framework, no Vue, no React.
cd docs-site
npm install
npm run build # markdown in content/ -> dist/
npm run serve # preview at http://localhost:4180
npm run dev # rebuild on save
Deploying it on Railway follows the frontend pattern:
- Create a service from this repository.
- Set its Config-as-code path to
deploy/railway.docs.toml. - Deploy. There are no environment variables and no volume.
To change the docs: edit the markdown in docs-site/content/, commit, and Railway
rebuilds. Adding a new page also requires an entry in docs-site/site.config.mjs —
the build deliberately fails if a page exists but is not listed in the navigation, or
vice versa, so pages cannot go quietly unreachable.
Note:
docs-site/is intentionally not an npm workspace of the root package. It keeps its ownnode_modulesand lockfile so the backend and frontend Docker builds, which copy the root lockfile, are unaffected by anything the docs depend on.
Local Docker
docker-compose.yml builds the non-Railway Dockerfiles (Dockerfile.backend,
Dockerfile.frontend) and is for development only. It mounts source read-only for hot
reload and stores the database in a named db-data volume.
docker compose up --build
docker compose down # add -v to wipe the database volume
Backups
There is no automatic backup. The database is a single SQLite file on the Railway volume.
Before anything risky — a schema change, a bulk delete, end of semester — take a copy. See Semester reset → Backing up first.
Next
→ Troubleshooting: when something is not working.