diff --git a/.railway/railway.ts b/.railway/railway.ts
new file mode 100644
index 00000000..de2d77c7
--- /dev/null
+++ b/.railway/railway.ts
@@ -0,0 +1,188 @@
+// Railway Infrastructure as Code - Outpost services
+// https://docs.railway.com/infrastructure-as-code
+//
+// Takes over from the per-service railway.toml files (Config as Code), which
+// Railway stops reading on 2026-12-01 (same page). Unlike Config as Code, this
+// file is NOT read at deploy time: changes reach Railway only through
+// `railway config apply`, so editing a setting here and merging does nothing
+// until someone applies it. Needs Railway CLI 5.42.1 or newer.
+//
+// `partial` limits what this file owns to the services declared below. A
+// whole-project file deletes every resource it omits on apply, and this file
+// does not declare the Postgres databases, so without it an apply would plan to
+// delete them.
+//
+// Cutover. The change that adds this file also deletes the railway.toml files,
+// so a service still pointed at one has no build config until it is applied.
+// 1. Before merging: `railway config pull` in each environment and copy its
+// variables in as preserve(), along with its domains (custom and
+// *.up.railway.app: the webhook services' URLs are registered with
+// GitHub, Linear and Teams). Then add the environment to
+// VARIABLES_IMPORTED.
+// 2. When the change reaches an environment's deploy branch (`main` for
+// production, `staging` for staging), in the same sitting:
+// a. Clear every service's "Config file path" in the dashboard. IaC
+// cannot manage a service that Config as Code still manages.
+// b. `railway config plan`. It may change only the build and deploy
+// settings this file sets. Stop if it removes or changes anything else:
+// variables, domains, replicas, dashboard-only settings, the deploy
+// trigger of slack-bot, teams-bot or linear-sync, or (in staging) an
+// outpost-release-bot service.
+// c. `railway config apply`.
+//
+// Build context is the repo ROOT for every service (the Dockerfiles run
+// `turbo prune`), so no service sets a root directory.
+
+import { defineRailway, github, service } from 'railway/iac';
+
+export const partial = 'outpost-services';
+
+// Environments whose variables from `railway config pull` are in this file.
+// Each one is listed separately because staging and production hold different
+// values: importing staging's does nothing to protect production's.
+const VARIABLES_IMPORTED: Environment[] = [];
+
+// Each environment deploys from its own branch: staging from `staging`,
+// production from `main`. Anything else is an error rather than a silent
+// fallback to `main`.
+const BRANCHES = {
+ production: 'main',
+ staging: 'staging',
+} as const;
+
+type Environment = keyof typeof BRANCHES;
+
+const isEnvironment = (name: string): name is Environment => Object.hasOwn(BRANCHES, name);
+
+export default defineRailway((ctx, project) => {
+ const environment = ctx.environmentName ?? ctx.environment;
+ if (!environment) {
+ throw new Error(
+ 'No Railway environment selected. Link one with `railway environment` first.',
+ );
+ }
+ if (!isEnvironment(environment)) {
+ throw new Error(
+ `No branch mapped for Railway environment "${environment}". ` +
+ `Known environments: ${Object.keys(BRANCHES).join(', ')}.`,
+ );
+ }
+ const branch = BRANCHES[environment];
+
+ // Railway does not document which command it passes here, so a run without
+ // one is treated as read-only. That
+ // makes this guard catch an apply only when the CLI says `apply`; the plan
+ // check in cutover step 2b stays the real safety net.
+ const readOnly = ctx.command === undefined || ctx.command === 'plan' || ctx.command === 'pull';
+ if (!VARIABLES_IMPORTED.includes(environment) && !readOnly) {
+ throw new Error(
+ `Variables for "${environment}" are not declared in .railway/railway.ts yet, so an apply would delete them. ` +
+ 'Import them with `railway config pull` first (see the cutover steps at the top of the file).',
+ );
+ }
+
+ // checkSuites turns on "wait for CI": a push deploys only after the CI check
+ // suite passes on that commit, as both environments do today.
+ const deployed = github('CopilotKit/outpost', { branch, checkSuites: true });
+
+ // slack-bot, teams-bot and linear-sync are optional integrations, deployed
+ // by hand and left offline until their credentials are configured, so this
+ // file does not set a branch for them. github() defaults the branch to
+ // `main`, which would add a deploy trigger, so it is passed as null; the SDK
+ // then drops the key, leaving the branch out of the plan. Cutover step 2b
+ // checks that Railway reads that as "no change".
+ const manual = github('CopilotKit/outpost', { branch: null });
+
+ const dockerfile = (app: string) => ({
+ builder: 'DOCKERFILE' as const,
+ dockerfilePath: `apps/${app}/Dockerfile`,
+ });
+
+ // web's healthcheck was set in the dashboard, not in its railway.toml. It is
+ // declared here so it is version-controlled like the other services'. Its
+ // other dashboard-only settings are left out; cutover step 2b checks that
+ // the plan leaves them alone.
+ const web = service('outpost-web', {
+ source: deployed,
+ build: dockerfile('web'),
+ deploy: { healthcheckPath: '/api/health' },
+ });
+
+ const discordBot = service('outpost-discord-bot', {
+ source: deployed,
+ build: dockerfile('discord-bot'),
+ deploy: { restartPolicyType: 'ALWAYS' },
+ });
+
+ const githubApp = service('outpost-github-app', {
+ source: deployed,
+ build: dockerfile('github-app'),
+ deploy: { restartPolicyType: 'ALWAYS', healthcheckPath: '/health' },
+ });
+
+ const linearSync = service('outpost-linear-sync', {
+ source: manual,
+ build: dockerfile('linear-sync'),
+ deploy: { restartPolicyType: 'ALWAYS', healthcheckPath: '/health' },
+ });
+
+ const slackBot = service('outpost-slack-bot', {
+ source: manual,
+ build: dockerfile('slack-bot'),
+ deploy: { restartPolicyType: 'ALWAYS' },
+ });
+
+ const teamsBot = service('outpost-teams-bot', {
+ source: manual,
+ build: dockerfile('teams-bot'),
+ deploy: { restartPolicyType: 'ALWAYS', healthcheckPath: '/health' },
+ });
+
+ const worker = service('outpost-worker', {
+ source: deployed,
+ build: dockerfile('worker'),
+ deploy: { restartPolicyType: 'ALWAYS', healthcheckPath: '/health' },
+ });
+
+ const resources = [web, discordBot, githubApp, linearSync, slackBot, teamsBot, worker];
+
+ // The release bot runs in production only: it posts to the community
+ // Discord channels, so a staging copy would announce the same releases twice.
+ if (environment === 'production') {
+ // A cron job, not a long-running process: it announces whatever is new
+ // and exits. restartPolicyType NEVER because a completed run exiting 0
+ // is success, not a crash; ALWAYS would restart it in a loop and run the
+ // job back to back.
+ //
+ // watchPatterns keeps unrelated pushes from redeploying it. Every entry
+ // is anchored with a leading slash: these are gitignore-style patterns,
+ // so an unanchored `package.json` would match every package.json in the
+ // repo. If the release bot ever depends on a workspace package, add
+ // `/packages/**` here, or changes to that package will not redeploy it.
+ const releaseBot = service('outpost-release-bot', {
+ source: deployed,
+ build: {
+ ...dockerfile('release-bot'),
+ watchPatterns: [
+ '/apps/release-bot/**',
+ '!/apps/release-bot/README.md',
+ '!/apps/release-bot/.env.example',
+ '!/apps/release-bot/src/__tests__/**',
+ '!/apps/release-bot/vitest.config.ts',
+ '!/apps/release-bot/tsconfig.test.json',
+ '/package.json',
+ '/pnpm-lock.yaml',
+ '/pnpm-workspace.yaml',
+ '/.npmrc',
+ '/tsconfig.json',
+ '/turbo.json',
+ '/.dockerignore',
+ ],
+ },
+ deploy: { restartPolicyType: 'NEVER', cronSchedule: '0 * * * *' },
+ });
+ resources.push(releaseBot);
+ }
+
+ return project('outpost', { resources });
+});
diff --git a/apps/discord-bot/railway.toml b/apps/discord-bot/railway.toml
deleted file mode 100644
index 885ea082..00000000
--- a/apps/discord-bot/railway.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-# Railway config — outpost-discord-bot (background worker, no public port)
-# In Railway: set this service's "Config file path" to apps/discord-bot/railway.toml
-# and leave Root Directory empty (build context must be the repo root).
-
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/discord-bot/Dockerfile"
-
-[deploy]
-restartPolicyType = "ALWAYS"
diff --git a/apps/docs/deployment/index.html b/apps/docs/deployment/index.html
index 4c3bf6b8..c4c333f9 100644
--- a/apps/docs/deployment/index.html
+++ b/apps/docs/deployment/index.html
@@ -134,7 +134,7 @@
Platform-Specific Guides
Railway (Recommended)
Railway auto-deploys from GitHub and natively supports Docker-based services.
- The repo includes a railway.toml config for all five services.
+ The repo includes a .railway/railway.ts config for all services, applied with railway config apply.
- Fork or push the repo to GitHub
diff --git a/apps/github-app/railway.toml b/apps/github-app/railway.toml
deleted file mode 100644
index ff6ec705..00000000
--- a/apps/github-app/railway.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Railway config — outpost-github-app (webhook receiver, public port)
-# In Railway: set this service's "Config file path" to apps/github-app/railway.toml
-# and leave Root Directory empty (build context must be the repo root).
-
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/github-app/Dockerfile"
-
-[deploy]
-restartPolicyType = "ALWAYS"
-healthcheckPath = "/health"
diff --git a/apps/linear-sync/railway.toml b/apps/linear-sync/railway.toml
deleted file mode 100644
index 64201e2c..00000000
--- a/apps/linear-sync/railway.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Railway config — outpost-linear-sync (webhook receiver, public port)
-# In Railway: set this service's "Config file path" to apps/linear-sync/railway.toml
-# and leave Root Directory empty (build context must be the repo root).
-
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/linear-sync/Dockerfile"
-
-[deploy]
-restartPolicyType = "ALWAYS"
-healthcheckPath = "/health"
diff --git a/apps/release-bot/README.md b/apps/release-bot/README.md
index 45493cdf..cc519aa6 100644
--- a/apps/release-bot/README.md
+++ b/apps/release-bot/README.md
@@ -321,7 +321,7 @@ summary written to replace them.
## Deployment
-A Railway cron service. `railway.toml` carries the build config, the restart
+A Railway cron service. Its entry in the repo's `.railway/railway.ts` carries the build config, the restart
policy, the schedule and the watch patterns, so a recreated service is still
scheduled rather than running once at deploy and never again, and an unrelated
push elsewhere in the monorepo does not trigger an extra run.
@@ -364,8 +364,8 @@ channel at once could both decide the same release is unannounced and both post
it. It also means the interval is not a safety limit: Railway allows anything
down to 5 minutes, and hourly is a choice about cost and channel noise.
-To change it, edit `cronSchedule` in `railway.toml` and redeploy. Railway only
-applies the schedule from that file on a deploy.
+To change it, edit `cronSchedule` in `.railway/railway.ts` and run
+`railway config apply`. Railway does not read that file on a deploy.
### YouTube API key
@@ -397,11 +397,11 @@ allowance of 10,000 a day.
1. In the Outpost Railway project, add a service from the `CopilotKit/outpost`
repo, named `outpost-release-bot`.
-2. Under Settings -> Config-as-code, set the config file path to
- `apps/release-bot/railway.toml`. Leave Root Directory empty: the build context
- has to be the repo root, because the Dockerfile runs `turbo prune` across the
- monorepo. The file sets the Dockerfile build, the hourly schedule, the restart
- policy and the watch patterns, so none of those need setting in the dashboard.
+2. Leave Root Directory and the config file path empty: the build context has
+ to be the repo root, because the Dockerfile runs `turbo prune` across the
+ monorepo. Run `railway config apply`: `.railway/railway.ts` sets the
+ Dockerfile build, the hourly schedule, the restart policy and the watch
+ patterns, so none of those need setting in the dashboard.
3. Set the variables from the table above under Variables. At minimum
`DISCORD_BOT_TOKEN`, plus a channel id per source you want running. Release
sources also need `OPENAI_API_KEY` and `GITHUB_TOKEN`.
diff --git a/apps/release-bot/railway.toml b/apps/release-bot/railway.toml
deleted file mode 100644
index a61292fb..00000000
--- a/apps/release-bot/railway.toml
+++ /dev/null
@@ -1,81 +0,0 @@
-# Railway config - outpost-release-bot (scheduled community announcements)
-# In Railway: set this service's "Config file path" to apps/release-bot/railway.toml
-# and leave Root Directory empty (build context must be the repo root).
-#
-# This service is a CRON JOB, not a long-running process: it announces whatever is
-# new and exits.
-#
-# cronSchedule is set here rather than in the dashboard so a recreated service is
-# still scheduled. Without it, the service runs once at deploy and then never
-# again, with no error anywhere to say so.
-#
-# restartPolicyType = "NEVER" because a completed run exiting 0 is success, not a
-# crash. ALWAYS would restart it in a loop, running the job back to back.
-
-# watchPatterns keeps unrelated pushes from redeploying this service. A deploy
-# can start a run outside the schedule (Railway's docs do not say either way),
-# so without it every merge anywhere in the monorepo could fire an extra run.
-#
-# Every entry is anchored with a leading slash. These are gitignore-style
-# patterns, so an unanchored `package.json` matches all 12 in the repo and an
-# unanchored `tsconfig.json` matches all 15 - a Renovate bump on apps/web would
-# have redeployed this service, so the list was causing the redeploys it exists
-# to prevent.
-#
-# It narrows the problem rather than removing it. /pnpm-lock.yaml is watched
-# unconditionally, and every dependency bump anywhere in the monorepo rewrites
-# that file, so those still redeploy this service even though `turbo prune`
-# produces a byte-identical context. Dropping it is worse: a turbo or typescript
-# bump does change the image, and watchPatterns cannot express "only if the
-# pruned lockfile changed".
-#
-# It has to name every root file that changes this image, not just the app's own
-# directory: a pattern missing here means the image silently goes stale instead
-# of rebuilding. It stays correct only while this package has no workspace
-# dependencies; the first one means adding /packages/** too.
-#
-# package.json carries the root devDependencies and the packageManager field the
-# pruner and installer stages both pin against, and .dockerignore decides what
-# the build context contains at all - a change to either produces a different
-# image from identical app sources.
-#
-# The negations are the same argument from the other side: docs and tests do not
-# reach the image - tsconfig.json excludes tests from emit - so without them,
-# editing this README or a test file redeploys the service and fires an
-# announcement run off schedule.
-#
-# This file is deliberately NOT negated. It does not change the image either, but
-# it carries cronSchedule and restartPolicyType, and only a redeploy applies
-# those. Excluded, a schedule change would sit committed and inert until some
-# unrelated watched file happened to move.
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/release-bot/Dockerfile"
-watchPatterns = [
- "/apps/release-bot/**",
- "!/apps/release-bot/README.md",
- "!/apps/release-bot/.env.example",
- "!/apps/release-bot/src/__tests__/**",
- "!/apps/release-bot/vitest.config.ts",
- "!/apps/release-bot/tsconfig.test.json",
- "/package.json",
- "/pnpm-lock.yaml",
- "/pnpm-workspace.yaml",
- "/.npmrc",
- "/tsconfig.json",
- "/turbo.json",
- "/.dockerignore",
-]
-
-[deploy]
-restartPolicyType = "NEVER"
-# Hourly, so a release or video is posted within the hour rather than up to a
-# day late. A run with nothing new to post almost never calls OpenAI; it reads
-# channel history back 30 days (usually one page), the latest releases per
-# source, and one YouTube API request when the key is set.
-#
-# Runs normally take seconds. Railway does not start a scheduled run while the
-# previous one is still active, so scheduled runs never overlap. Railway's
-# minimum interval is 5 minutes; hourly is a choice about cost and noise, not a
-# safety limit. See the README's Schedule section.
-cronSchedule = "0 * * * *"
diff --git a/apps/slack-bot/railway.toml b/apps/slack-bot/railway.toml
deleted file mode 100644
index 8fbe6a62..00000000
--- a/apps/slack-bot/railway.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-# Railway config — outpost-slack-bot (background worker, no public port)
-# In Railway: set this service's "Config file path" to apps/slack-bot/railway.toml
-# and leave Root Directory empty (build context must be the repo root).
-
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/slack-bot/Dockerfile"
-
-[deploy]
-restartPolicyType = "ALWAYS"
diff --git a/apps/teams-bot/railway.toml b/apps/teams-bot/railway.toml
deleted file mode 100644
index eecaa9d3..00000000
--- a/apps/teams-bot/railway.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Railway config — outpost-teams-bot (webhook receiver, public port)
-# In Railway: set this service's "Config file path" to apps/teams-bot/railway.toml
-# and leave Root Directory empty (build context must be the repo root).
-
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/teams-bot/Dockerfile"
-
-[deploy]
-restartPolicyType = "ALWAYS"
-healthcheckPath = "/health"
diff --git a/apps/worker/railway.toml b/apps/worker/railway.toml
deleted file mode 100644
index d99b38bf..00000000
--- a/apps/worker/railway.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-# Railway config — outpost-worker (background job processor)
-# In Railway: set this service's "Config file path" to apps/worker/railway.toml
-# and leave Root Directory empty (build context must be the repo root).
-
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/worker/Dockerfile"
-
-[deploy]
-restartPolicyType = "ALWAYS"
-healthcheckPath = "/health"
diff --git a/docs/deployment.md b/docs/deployment.md
index 094ecc9a..84f9872c 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -17,7 +17,7 @@ Railway auto-deploys from GitHub and natively supports Docker-based services.
2. Create a new project on [Railway](https://railway.app)
3. Add a **PostgreSQL** service (Railway has native Postgres with pgvector support)
4. Enable pgvector: connect to the database and run `CREATE EXTENSION IF NOT EXISTS vector;`
-5. Add seven services from the repo, each pointing to its Dockerfile and `railway.toml` (set each service's Config file path to `apps//railway.toml`, Root Directory empty — build context must be repo root):
+5. Add seven services from the repo, each pointing to its Dockerfile. Their build and deploy settings live in `.railway/railway.ts` and reach Railway through `railway config apply` (leave each service's Config file path and Root Directory empty — build context must be repo root):
- **outpost-web** — `apps/web/Dockerfile` (web service, port 3000, health check `/api/health`)
- **outpost-discord-bot** — `apps/discord-bot/Dockerfile` (background worker, no public URL needed — gateway connects outbound)
- **outpost-github-app** — `apps/github-app/Dockerfile` (web service, port 3200, needs public URL for webhooks)
@@ -127,7 +127,7 @@ Outpost's GitHub integration (`apps/github-app`) responds to issues and discussi
Optional: `GITHUB_TEAM_LOGINS` (comma-separated GitHub logins treated as internal team members, used by triage logic).
-5. **Deploy.** `outpost-github-app` is already defined as a Railway service (see the Quick Start section above) — point its Config file path at `apps/github-app/railway.toml`, leave Root Directory empty, add the env vars, deploy. Health check hits `GET /health`.
+5. **Deploy.** `outpost-github-app` is already defined as a Railway service (see the Quick Start section above) — its build and deploy settings come from `.railway/railway.ts` (run `railway config apply`), leave Root Directory empty, add the env vars, deploy. Health check hits `GET /health`.
6. **Verify.** Open an issue on the installed repo. The agent should reply with an AI-generated answer plus a "Was this helpful? 👍/👎" prompt. React to it, then either wait for the next 24h poll or trigger `GITHUB_REACTION_POLL` manually to confirm the reaction lands as `feedback` on the `Message` row.
diff --git a/package.json b/package.json
index c5e0e73a..cc5618f7 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"@typescript-eslint/parser": "^8.58.2",
"eslint": "^9.16.0",
"prettier": "^3.2.0",
+ "railway": "3.16.0",
"tsx": "^4.21.0",
"turbo": "^2.3.0",
"typescript": "^5.7.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 64b58cf0..db785815 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
prettier:
specifier: ^3.2.0
version: 3.8.3
+ railway:
+ specifier: 3.16.0
+ version: 3.16.0
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -4015,6 +4018,11 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ railway@3.16.0:
+ resolution: {integrity: sha512-//vKXubZpO07iJgNyoS+9Ee3pKk+3wE+0KAJPI28GuSKqXG30UeWT4xBkkd6TYuZ4HNh7QPvkVR72Swbmq5kXw==}
+ engines: {node: '>=22'}
+ hasBin: true
+
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -8950,6 +8958,11 @@ snapshots:
queue-microtask@1.2.3: {}
+ railway@3.16.0:
+ dependencies:
+ '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2)
+ graphql: 16.13.2
+
range-parser@1.2.1: {}
raw-body@3.0.2:
diff --git a/railway.toml b/railway.toml
deleted file mode 100644
index a4c2b9fa..00000000
--- a/railway.toml
+++ /dev/null
@@ -1,32 +0,0 @@
-# Railway configuration — outpost-web (root / default config)
-# https://docs.railway.com/reference/config-as-code
-#
-# Railway config-as-code is SINGLE-SERVICE per file (top-level [build]/[deploy]).
-# There is NO [services.] multi-service syntax. The previous version of this
-# file used [services.*] blocks, which Railway silently IGNORED — every service then
-# fell back to the auto-detect (Railpack) builder and failed to build with
-# "No start command detected".
-#
-# Monorepo layout — each service has its own single-service config file:
-# outpost-web -> railway.toml (this file, the default root config)
-# outpost-discord-bot -> apps/discord-bot/railway.toml
-# outpost-github-app -> apps/github-app/railway.toml
-# outpost-slack-bot -> apps/slack-bot/railway.toml
-# outpost-teams-bot -> apps/teams-bot/railway.toml
-# outpost-linear-sync -> apps/linear-sync/railway.toml
-# outpost-worker -> apps/worker/railway.toml
-# outpost-release-bot -> apps/release-bot/railway.toml
-#
-# Each non-web service must point its "Config file path" (Railway dashboard ->
-# service -> Settings -> Config-as-code) at its file above. Build context is the
-# repo ROOT for every service (the Dockerfiles run `turbo prune`), so leave each
-# service's Root Directory empty.
-#
-# NOTE: web's [deploy] settings (healthcheck, etc.) stay managed in the Railway
-# dashboard and are intentionally NOT overridden here, to avoid changing the live
-# service. Add a [deploy] section if/when you want them version-controlled
-# (web's health endpoint is /api/health).
-
-[build]
-builder = "DOCKERFILE"
-dockerfilePath = "apps/web/Dockerfile"