# Railway deployment

How izri deploys to [Railway](https://railway.com): 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`](../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

```bash
# 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:

```bash
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:**

```bash
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`](../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:

```bash
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.
