Railway deployment
How izri deploys to Railway: three application services (api, web, ai-service) plus two managed plugins (Postgres, Redis), running in two environments (staging and production) inside a single Railway project.
Layout
Railway project: izri
├── environment: staging (auto-deploys from main branch pushes)
├── environment: production (deploys only from git tags via GitHub Actions)
│
├── service: api → docker/api/Dockerfile
├── service: web → docker/web/Dockerfile
├── service: ai-service → docker/ai-service/Dockerfile
├── plugin: Postgres (separate database per environment)
└── plugin: Redis (separate instance per environment)
Repo layout:
deploy/
├── api/railway.toml
├── web/railway.toml
└── ai-service/railway.toml
docker/
├── api/Dockerfile
├── web/Dockerfile
└── ai-service/Dockerfile
Each deploy/<service>/railway.toml declares builder = "DOCKERFILE" and points dockerfilePath at docker/<service>/Dockerfile (paths relative to the repository root).
Per-service dashboard config
Every service is configured the same way:
| Setting | Value |
|---|---|
| Root Directory | (empty) — build context = repo root |
| Config-as-code Path | deploy/<service>/railway.toml |
| Custom Dockerfile Path | (none) — set in railway.toml |
| Custom Start Command | (none) — set in railway.toml |
| Healthcheck Path | (none) — set in railway.toml |
Root Directory must be empty. It governs the Docker build context, and the pnpm workspace requires the repository root as the build context (the Dockerfiles COPY . . and then run pnpm install -r to resolve workspace links). Setting Root Directory to a subdirectory would break the build.
The dockerfilePath and startCommand (where set) inside each railway.toml are therefore expressed relative to the repository root.
Health checks
| Service | Path | Implemented at |
|---|---|---|
| api | /health |
apps/api/src/routes/health.ts |
| web | / |
React Router SSR root route |
| ai-service | /health |
packages/ai-service/main.py |
Healthcheck paths are declared inside each service's railway.toml.
Migrations
The api Dockerfile runs drizzle-kit migrate automatically before starting the server. From docker/api/Dockerfile:
CMD ["sh", "-c", "cd /app/packages/database && node_modules/.bin/drizzle-kit migrate && cd /app && exec node apps/api/dist/index.js"]
There is no separate migration step in the deploy pipeline. Every api boot applies any pending migrations before the server takes over PID 1.
Migration invariant: every migration MUST be backward-compatible with the previously running api image. During a deploy, the new container starts and migrates while the old container is still serving traffic; if the migration breaks the old schema shape the previous instance crashes. Same applies during a tag-rollback — the older image still tries to run migrate, finds no pending migrations, and continues.
Staging environment
Auto-deploys on every push to main.
- Postgres + Redis: separate plugin instances from production. Do not point staging env vars at production plugins.
- GitHub OAuth: needs its own OAuth app with the staging callback URL — see
docs/setup/GITHUB_OAUTH.md.
Staging env vars use the same shape as production (below) but with staging URLs and the staging OAuth app credentials.
Production environment
Deploys only from git tags matching v*, via .github/workflows/deploy-prod.yaml.
- Main-branch auto-deploy is disabled on the production services. The only path into production is a tag push.
How the tag-based deploy works
.github/workflows/deploy-prod.yaml runs on push: tags: ['v*'] and has two jobs:
guard— checks out the repo and verifies the tag SHA is reachable fromorigin/mainviagit merge-base --is-ancestor. Tags pointing at commits not onmainare rejected.deploy— runs in a 3-way matrix (one job per service) and calls Railway's GraphQL API:
The mutation tells Railway to deploy the tag's commit SHA to the named service.mutation { serviceInstanceDeploy(environmentId, serviceId, commitSha) }
Required GitHub repository secret: RAILWAY_PROJECT_TOKEN — generate it in the Railway dashboard under Project Settings → Tokens, scoped to the izri project. Rotate via the same UI; whoever has Railway project admin can do this.
First-deploy runbook
# from a clean checkout of main
git tag v0.0.1
git push origin v0.0.1
Then in the GitHub Actions tab, watch the Deploy to production workflow. The guard job should pass; the three matrix legs should each report serviceInstanceDeploy: true.
Verify after the deploys land:
curl https://<api-public-domain>/health
# expect: {"ok":true}
- Open the web URL and complete a GitHub OAuth round-trip end-to-end.
- Confirm the api can reach
ai-serviceover its private domain (any tRPC call that hits the AI service will exercise this). - Tail the api logs in Railway and confirm
drizzle-kit migratereported all migrations applied (or already applied if staging ran first).
Rollback
Two options, both valid:
A. Re-tag a previous good SHA:
git tag v0.0.2 <previous-good-sha>
git push origin v0.0.2
The workflow re-runs and Railway redeploys that SHA.
B. Use the Railway dashboard:
Open the production environment → service → Deployments tab → find the last successful deploy → Redeploy. This is faster than option A and doesn't require a new tag, but it leaves the source-of-truth (tags) and the running revision out of sync.
Prefer option A when you have time; option B for emergency rollbacks.
Note on staging vs production timing
A push to main deploys to staging immediately. A subsequent tag on the same SHA deploys the same code to production. So staging acts as a brief soak environment for whatever you're about to release. Don't tag a SHA you haven't seen healthy on staging.
Environment variables
Reference variables (${{ServiceName.VAR}} and ${{Plugin.VAR}}) are wired automatically by Railway — paste them as-is.
api
| Variable | Value |
|---|---|
NODE_ENV |
production |
DATABASE_URL |
${{Postgres.DATABASE_URL}} |
REDIS_URL |
${{Redis.REDIS_URL}} |
API_URL |
https://${{RAILWAY_PUBLIC_DOMAIN}} (after a public domain is generated) |
APP_URL |
https://${{web.RAILWAY_PUBLIC_DOMAIN}} |
AI_SERVICE_URL |
http://${{ai-service.RAILWAY_PRIVATE_DOMAIN}}:${{ai-service.PORT}} |
BETTER_AUTH_SECRET |
random 32+ char string |
JWT_SECRET |
random 32+ char string |
GITHUB_CLIENT_ID |
OAuth app client id |
GITHUB_CLIENT_SECRET |
OAuth app client secret |
web
VITE_* vars are baked into the bundle at build time, so they must be set before the first deploy and the service must rebuild after they change.
| Variable | Value |
|---|---|
NODE_ENV |
production |
VITE_API_URL |
https://${{api.RAILWAY_PUBLIC_DOMAIN}} |
VITE_APP_URL |
https://${{RAILWAY_PUBLIC_DOMAIN}} |
API_URL |
https://${{api.RAILWAY_PUBLIC_DOMAIN}} |
APP_URL |
https://${{RAILWAY_PUBLIC_DOMAIN}} |
BETTER_AUTH_SECRET |
same value as the api service |
ai-service
| Variable | Value |
|---|---|
OPENAI_API_KEY |
OpenAI API key (optional) |
ANTHROPIC_API_KEY |
Anthropic API key (optional) |
Public domains
api and web need public domains; generate them under each service's Settings → Networking → Generate Domain. ai-service only needs its private domain so it isn't exposed to the internet.
After domains are assigned, the env vars that reference them (API_URL, APP_URL, VITE_*) need to be filled in and the affected services redeployed.
GitHub OAuth callback
Each environment needs its own GitHub OAuth app, with the Authorization callback URL set to:
https://<api-public-domain>/api/auth/callback/github
API_URL and the OAuth callback must match exactly or login will fail silently. See docs/setup/GITHUB_OAUTH.md for details on the staging app.
Local sanity check
To validate a Dockerfile builds the way Railway will, run from the repo root:
docker build -f docker/api/Dockerfile -t izri-api .
docker build -f docker/web/Dockerfile -t izri-web .
docker build -f docker/ai-service/Dockerfile -t izri-ai .
The . at the end is the build context — same as what Railway uses when Root Directory is empty.