Docs /deployment/railway

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:

  1. guard — checks out the repo and verifies the tag SHA is reachable from origin/main via git merge-base --is-ancestor. Tags pointing at commits not on main are rejected.
  2. deploy — runs in a 3-way matrix (one job per service) and calls Railway's GraphQL API:
    mutation { serviceInstanceDeploy(environmentId, serviceId, commitSha) }
    
    The mutation tells Railway to deploy the tag's commit SHA to the named service.

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-service over its private domain (any tRPC call that hits the AI service will exercise this).
  • Tail the api logs in Railway and confirm drizzle-kit migrate reported 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.

Reading this with an agent? /docs/deployment/railway.md serves the raw markdown.

All docs