Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .railway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Railway configuration

`railway.ts` defines Coachatron's `web` service for every environment
(development from `develop`, uat from `staging`, production from `main`). It
replaced `railway.json`, which Railway stops reading on 2026-12-01.

Railway does not read this folder on deploy. Changes take effect only when
someone applies them, one environment at a time, from the repo root on Node 24:

```bash
railway environment development # then uat, then production
railway config plan # must say 0 to destroy
railway config apply
```

- **Partial `web`.** The file owns only the web service. Postgres, its volume,
and slack-cards are not in it and apply never touches them.
- **Variables.** Every value stays in Railway; the file marks each one
`preserve()`. Never put a secret here.
- **Node version.** `RAILPACK_NODE_VERSION` comes from `../.nvmrc`. Railpack
reads that variable before `package.json` engines, so it decides the Node a
deploy runs. To apply everything except a Node change, set
`COACHATRON_IAC_PRESERVE_NODE=1`.
- **Known Railway bug (production).** `railway config apply` with any change
fails in production with "Custom-domain registration is not supported" for
`coachatron.com` and `www.coachatron.com`, though both exist and `plan`
shows only the intended change. Until Railway fixes it, make the planned
change directly (for a variable: `railway variable set KEY=VALUE --service
web --environment production`), then run `railway config plan` to confirm
it says "already up to date".
67 changes: 67 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { readFileSync } from "node:fs";
import { defineRailway, github, preserve, project, service } from "railway/iac";

// Coachatron's web service, in every environment. Replaces railway.json
// (Config as Code), which Railway stops reading on 2026-12-01.
//
// Railway does not read this file on deploy. Preview and apply it per
// environment, from the repo root, with the environment linked:
// railway environment development # or uat, production
// railway config plan
// railway config apply
//
// "web" is a partial: this file owns only the web service. Postgres, its
// volume, and slack-cards (production) are managed elsewhere and are never
// touched by apply.
export const partial = "web";

// The Node version comes from .nvmrc, the one place the repo names it
// (test/toolchain.test.ts keeps package.json engines in step). Railpack reads
// RAILPACK_NODE_VERSION before engines, so this variable is what decides the
// Node a deploy runs. COACHATRON_IAC_PRESERVE_NODE=1 keeps the live value
// instead, for applying the rest of this file without changing Node.
function nodeVersion() {
if (process.env.COACHATRON_IAC_PRESERVE_NODE === "1") return preserve();
return readFileSync(new URL("../.nvmrc", import.meta.url), "utf8").trim();
}

const ENVIRONMENTS = {
development: { branch: "develop", domains: ["dev.coachatron.com"] },
uat: { branch: "staging", domains: ["uat.coachatron.com"] },
production: { branch: "main", domains: ["coachatron.com", "www.coachatron.com"] },
} as const;

export default defineRailway((ctx) => {
const name = (Object.keys(ENVIRONMENTS) as Array<keyof typeof ENVIRONMENTS>).find((env) => ctx.isEnvironment(env));
if (!name) throw new Error(`No web settings for Railway environment "${ctx.environmentName}"`);
const env = ENVIRONMENTS[name];

const web = service("web", {
source: github("YOLOVibeCode/coachatron", { branch: env.branch, checkSuites: false }),
build: "npm run build",
start: "npm start",
healthcheck: "/",
healthcheckTimeout: 30,
replicas: { "us-east4-eqdc4a": 1 },
domains: env.domains.map((domain) => ({ domain, port: 3000 })),
// Values live in Railway, never in this file. preserve() keeps each one.
env: {
APP_BASE_URL: preserve(),
APP_ENV: preserve(),
DATABASE_URL: preserve(),
LITELLM_API_KEY: preserve(),
LITELLM_BASE: preserve(),
NODE_ENV: preserve(),
PORT: preserve(),
RAILPACK_NODE_VERSION: nodeVersion(),
RELAY_API_KEY: preserve(),
RELAY_BASE_URL: preserve(),
RELAY_CONNECT_PRODUCT: preserve(),
RELAY_WEBHOOK_SECRET: preserve(),
SESSION_SECRET: preserve(),
STORE_BASE_URL: preserve(),
},
});

return project("coachatron", { resources: [web] });
});
49 changes: 49 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1177,3 +1177,52 @@ Notes:
- To move to the next LTS: change `.nvmrc` and `engines` together (the
toolchain test enforces it), after the `Next Node (lts/*)` job is green.

---

## M12: Railway Infrastructure as Code
Status: [x] done
Goal: Retire `railway.json` (Config as Code, which Railway stops reading on
2026-12-01) without changing anything that runs, and let `.nvmrc` decide
production's Node.
Acceptance:
- [x] `.railway/railway.ts` describes the `web` service in development
(`develop`), uat (`staging`) and production (`main`): source, build,
start, healthcheck, replicas, custom domains, and every variable as
`preserve()` (values never in git)
- [x] It is a `web` partial: Postgres, its volume, and slack-cards are not
declared and apply cannot touch them
- [x] `railway config plan` in all three environments: "already up to
date" (with the Node line preserved), so the file matches what runs;
applied to all three, which recorded ownership and changed nothing
- [x] `railway config migrate --apply --service web` run in all three:
no Config File setting was set, so nothing to clear
- [x] `railway.json` deleted; `test/toolchain.test.ts` fails if it returns,
if the file stops reading `.nvmrc`, or if it declares Postgres or
slack-cards
- [x] All five quality-bar commands still exit 0

Notes:
- The first `migrate` output was not safe to apply: it named the service
`coachatron` (the live one is `web`), and with only build/start/healthcheck
declared, `plan` showed it would delete all 14 web variables and detach the
GitHub source. The file was rebuilt from `railway config pull` in each
environment instead.
- `RAILPACK_NODE_VERSION` comes from `.nvmrc`, so moving Node is a one-file
change. Applying that is the only pending change in each environment:
`plan` shows exactly one update, `web.RAILPACK_NODE_VERSION` (22 to 24).
It changes the runtime of a live service, so it is applied per
environment on purpose (development, then uat, then production), not as
part of this migration. `COACHATRON_IAC_PRESERVE_NODE=1` applies the rest
of the file without it.
- Applied 2026-10-01 with the owner's go-ahead, in order. development:
`config apply`, rebuilt `8cb1fb3`. uat: `config apply`, rebuilt `9f42394`.
production: `config apply` refused (a Railway bug that rejects the two
existing custom domains on any change), so the same single change was made
with `railway variable set`, rebuilt `97012f4`. All three now run Node
24.21.0 (read with `railway ssh`); `/` and `/signin` return 200 and a
database-backed 404 route answers on every domain; `config plan` is
"already up to date" in all three. No code changed in any deploy.
- The `railway` SDK checks the CLI version by running `$_`, the last command
the shell ran. Wrapping `railway config plan` in `timeout` (or anything
else) makes it fail with a misleading "requires Railway CLI 5.42.1".

39 changes: 39 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@types/node": "^24.19.0",
"@types/pg": "^8.11.6",
"eslint": "^10.11.0",
"railway": "^3.12.0",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.0"
Expand Down
11 changes: 0 additions & 11 deletions railway.json

This file was deleted.

15 changes: 13 additions & 2 deletions test/toolchain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

/** One Node version, read three ways: nvm and CI read .nvmrc, Railway
* (Railpack) reads package.json engines first. If they drift, a deploy runs
/** One Node version, read everywhere: nvm and CI read .nvmrc; Railway
* (Railpack) reads RAILPACK_NODE_VERSION first, which .railway/railway.ts
* sets from .nvmrc, then package.json engines. If they drift, a deploy runs
* a Node nobody tested. */

const root = new URL('..', import.meta.url);
Expand Down Expand Up @@ -31,3 +32,13 @@ test('the Node running these tests meets engines', () => {
`running Node ${process.version}, but package.json needs ${engines}. Run "nvm use" in this folder.`,
);
});

test('Railway takes its Node version from .nvmrc and no longer has railway.json', () => {
const iac = read('.railway/railway.ts');
assert.match(iac, /RAILPACK_NODE_VERSION: nodeVersion\(\)/);
assert.match(iac, /readFileSync\(new URL\("\.\.\/\.nvmrc", import\.meta\.url\)/);
assert.match(iac, /export const partial = "web";/, 'the file owns only the web service');
assert.doesNotMatch(iac, /\b(postgres|slack-cards)\(/, 'Postgres and slack-cards are managed elsewhere');
assert.throws(() => read('railway.json'), /ENOENT/, 'railway.json (Config as Code) is retired');
});

Loading