Deploy a project to Railway (live, auto-deploying, secure)
This is a companion to the official railway:use-railway skill — load that
too; it owns the CLI/MCP mechanics (auth, railway up, logs, the GraphQL
fallback). THIS skill is the opinionated end-to-end playbook + the gotcha list
that the generic skill doesn't have. When they disagree, the generic skill wins
on a command's exact flags; this skill wins on the sequence and the traps.
The model (read once)
- Workspace → billing scope. Project → one app + its DB(s). Service
→ one deployable (app, DB, or bucket). Environment →
production etc.
- Railway gives every service a private address (
<svc>.railway.internal)
and, on request, a public domain. Services in the same project reach each
other over the private network — keep databases private.
- Reference variables
${{ OtherService.VAR }} are how you share
credentials/values between services. Same-service self-refs work too:
${{ MY_OWN_VAR }}.
Decide the service topology FIRST
| Project shape |
Services to create |
| Full-stack framework (Next.js, Remix, SvelteKit, Rails, Django+templates) |
ONE app service. The framework serves UI and API from one process. Do NOT split it — two services for one codebase is wrong and leaves a dead/broken half. |
| Truly separate FE + BE (React/Vue SPA + a separate Node/Python/Go API) |
TWO services from the same or two repos. FE gets the public domain; BE stays private (or its own domain); FE reaches BE via ${{ backend.RAILWAY_PRIVATE_DOMAIN }} or a public URL. Set NEXT_PUBLIC_API_URL/VITE_API_URL on the FE to the BE's URL. If both live in ONE repo (monorepo), each service needs its own Root Directory — see the monorepo note below. |
| + background jobs / cron |
A separate service (same repo) with its own config file (cron schedule + start command + restartPolicy NEVER). |
| + object storage |
A bucket (project-level), creds shared to the services that need it. |
If the user says "separate front and back end" but the repo is a full-stack
framework, tell them it's one service and why — build the right thing.
Monorepo (FE + BE, or many packages, in one repo): each service needs a
Root Directory set to its package path (e.g. /frontend, /backend) — a
dashboard/MCP-only setting (like the cron Config File Path below), so hand the
user the click or set it via update_service. ⚠️ A railway.json/railway.toml
config file does NOT follow Root Directory — give it an ABSOLUTE path (e.g.
/backend/railway.toml), not one relative to the root dir. Shortcut: importing
the repo via railway.com/new can auto-stage one service per package for
pnpm/npm/yarn/bun workspaces. Pair this with per-service Watch Paths (gotcha #9)
so a push only rebuilds the service it touched.
The deploy sequence (CLI; the MCP is often unauthenticated — verify and fall back to CLI)
Prefix Railway CLI calls with RAILWAY_CALLER=skill:use-railway@<v> and a stable
RAILWAY_AGENT_SESSION. Background long commands (add --database, up) and
verify with railway service list, not the command's stdout — Railway's CLI
frequently prints Unauthorized/errors on a step that actually succeeded.
- Auth + workspace:
railway whoami --json (lists workspaces). If it fails,
the user runs railway login (their browser).
- Project:
railway init --name <project> --workspace <id> --json (links cwd).
- Postgres:
railway add --database postgres --json. ⚠️ It may print
Unauthorized yet still create it — check railway service list. If you
ran it twice, you'll have duplicates; delete extras:
railway service delete --service <name> --yes.
- App service from GitHub:
railway add --service <name> --repo <owner/repo> --branch main --json.
- Env vars (see the var recipe below) — set with
--skip-deploys, all at once:
railway variable --service <name> --skip-deploys --set 'K=V' --set 'K2=V2' …
Quote ${{…}} references in single quotes so the shell doesn't expand them.
- Domain:
railway domain --service <name> --json → returns the public URL.
Set APP_ORIGIN to it (the app needs its own public origin for OAuth
callbacks, links, WebAuthn RP id).
- Deploy + VERIFY: never report success on
up returning. Poll
railway deployment list --service <name> --limit 1 --json until status is
SUCCESS (deployed) or FAILED/CRASHED (triage logs). Then curl the
public /health (or /api/health) endpoint to confirm it's actually serving.
Set the service's healthcheckPath (e.g. /health, /api/health) so
Railway itself gates the traffic cutover on a 200 before going live — without
it, a broken new deploy silently replaces a working one. The default
healthcheck timeout is 300s; raise it via RAILWAY_HEALTHCHECK_TIMEOUT_SEC
for slow first boots/migrations. The check runs once at deploy time, NOT
continuously — a passing deploy is not uptime monitoring (use Uptime Kuma or
similar for that). Railway's healthcheck hits the app over the injected PORT
from healthcheck.railway.app, so gotchas #2 and #3 apply.
- Cron/worker service:
railway add --service <name> --repo … --branch main,
set its env, then set its Config File Path to the cron config (e.g.
/railway.cron.json) — that file carries deploy.cronSchedule +
deploy.startCommand + restartPolicy NEVER. ⚠️ The config-file-path is a
dashboard/MCP-only setting; the CLI can't set it. Tell the user the one
click, or set it via the update_service MCP tool / GraphQL if the MCP is authed.
The env-var recipe (Postgres + RLS-safe app)
The #1 production security trap: the app must connect as a non-superuser DB role
with RLS enforced, NOT the Railway default postgres superuser (superusers
BYPASS row-level security → cross-tenant data leak).
- Migrations run as the OWNER (create roles, RLS, schema). Reference the
Postgres service's owner creds:
PGHOST=${{ Postgres.PGHOST }}, PGPORT, PGUSER, PGPASSWORD, PGDATABASE
(all ${{ Postgres.* }}), plus the target DB name your migrator expects.
- App runtime connects as the app role, via a CUSTOM
DATABASE_URL — NOT
${{ Postgres.DATABASE_URL }} (that's the superuser):
DATABASE_URL=postgresql://<approle>:<APP_DB_PASSWORD>@${{ Postgres.RAILWAY_PRIVATE_DOMAIN }}:5432/<db>
- Rotate the app role's password. Most apps seed the role with a weak dev
password. Rotate it to a strong
APP_DB_PASSWORD (32-byte hex). The cleanest
spot is the migrator's pre-deploy step: after migrating, run
ALTER ROLE <approle> PASSWORD '<APP_DB_PASSWORD>' (literal-escaped — no bind
params for passwords; double single-quotes). Then the app's DATABASE_URL
(with APP_DB_PASSWORD) connects on the first boot.
- Generate secrets locally, don't hardcode:
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))".
- Set
APP_ORIGIN, any product-name/brand var, and a CRON_SECRET if there's a
cron→app POST.
Object storage (S3-compatible bucket) + sharing creds
railway bucket create --name <bucket> (project-level).
railway bucket credentials --bucket <bucket> --json → endpoint, access key,
secret, region.
- Share to the services that need it as variables (or references):
S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET,
S3_REGION. If the bucket exposes them as service vars, prefer
${{ <bucket>.* }} references so a rotation propagates.
Auth in front of the app
- Google SSO (app-native): the user creates a Google Cloud OAuth client
(Web); the authorized redirect URI is
${APP_ORIGIN}/api/auth/google/callback
(or whatever the app's callback route is — copy the EXACT path from the app, a
wrong path is the classic launch-day redirect_uri_mismatch). They set
GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET. You cannot create the OAuth client
for them — hand off with the exact callback URL filled in.
- Cloudflare Zero Trust (gateway auth, no app code): put a Cloudflare Access
policy in front of the public domain. The user: add the domain to Cloudflare
(DNS), create an Access application for it, pick an identity provider
(Google/GitHub/OTP), and write an allow policy (e.g. emails @company). Access
then challenges every request before it reaches Railway — good when the app has
no SSO of its own, or for locking a staging/admin URL. (Railway custom domain +
Cloudflare proxied DNS is the wiring; the Access app is the gate.)
GOTCHAS (the time-savers — check these the moment a build misbehaves)
- Build "schedules" then FAILS with ZERO output → Node version. NIXPACKS
can't provision bleeding-edge Node (e.g. 24, 25). If
.nvmrc/engines pins a
too-new major, the Nix env fails to evaluate before any build log prints. Fix:
set NIXPACKS_NODE_VERSION=22 (or another LTS the engines allow). This is the
single highest-value gotcha — a no-output build failure is almost always this.
- Deploy goes SUCCESS but the app healthcheck-fails / crash-loops → it's
binding
127.0.0.1 or a hardcoded port. Railway injects PORT and routes
traffic to it; an app listening on localhost/127.0.0.1 or a baked-in port
answers nobody, and Railway reports "service unavailable" despite a green build.
This is the #1 "deployed but not serving" failure (vendor- and cross-platform-
confirmed), and a different root cause than the Nixpacks gotchas above. The
server MUST listen on 0.0.0.0 AND read the injected PORT:
app.listen(process.env.PORT, '0.0.0.0'), uvicorn --host 0.0.0.0 --port $PORT,
gunicorn -b 0.0.0.0:$PORT. Dockerfile twist: exec-form CMD ["node","server.js"]
(JSON array) does NOT expand $PORT — use shell-form CMD node server.js /
CMD gunicorn -b 0.0.0.0:${PORT:-8000} app:app so the variable interpolates.
- Host-allowlist apps reject Railway's healthcheck → 400 / "service
unavailable" despite a healthy app. Railway's healthcheck request originates
from hostname
healthcheck.railway.app. Apps with a host allowlist — Django
ALLOWED_HOSTS, Rails config.hosts, Express helmet, some reverse proxies —
reject that Host header and the deploy fails even though the app is fine. Fix:
add healthcheck.railway.app to the allowlist (or scope * to the health
route). Looks like a Railway bug; it's an app-config issue.
npm ci "Missing @esbuild/@x from lock file" → npm version skew.
npm 11 (ships with Node 24+) prunes optional per-platform packages from the
lockfile that npm 10 (ships with Node 22) strictly requires. If you pin Node 22
on Railway but the lockfile was written by npm 11, npm ci fails. Fix:
regenerate the lockfile with the matching npm —
npx -y npm@10 install --package-lock-only — commit it (it only ADDS platform
entries, no version changes), verify with npx npm@10 ci --dry-run.
- "I set X but Railway ignores it" → build-method precedence. Any
Dockerfile
in the repo SILENTLY overrides Nixpacks and the DOCKER_IMAGE setting; a
railway.json/railway.toml deploy.startCommand SILENTLY overrides the
Dockerfile CMD. Before debugging Nixpacks, check for a stray Dockerfile or a
config startCommand — that's a distinct failure class from the Node/npm
gotchas above.
Unauthorized that actually succeeded. Railway's CLI/MCP throws auth-y
errors on add --database/provisioning that nonetheless create the resource.
Always re-verify with railway service list before retrying (retrying makes
duplicates).
- MCP unauthenticated even though the CLI is authed. The Railway MCP server
has its own token;
railway --help shows an "Agent tooling" health section. If
the MCP is ✗, just use the CLI (don't block on railway setup agent, which
needs a tool restart). Some settings (a service's Config File Path, Root
Directory, Watch Paths) are only reachable via MCP/dashboard, not the CLI.
- RLS bypass via DATABASE_URL. Covered above — the app's
DATABASE_URL must
be the app-role URL, never ${{ Postgres.DATABASE_URL }} (superuser).
- GitHub auto-deploy-on-merge needs the Railway GitHub App authorized on the
repo. Connecting a repo by name (
add --repo) sets the metadata, but the
first build won't auto-fire and merges won't deploy until the user installs/
authorizes Railway's GitHub App for that repo (railway.com → the service →
Settings → Source, or Account → Integrations → GitHub). Until then, deploy with
railway up (deploys the local checkout) to get it live, and hand off the
one-time App authorization to enable the watch. Monorepo with multiple
services: set per-service Watch Paths (gitignore-style globs, e.g.
/backend/**) — without them, every merge to main rebuilds and redeploys EVERY
service in the project, not just the one that changed. Watch Paths are a
dashboard/MCP-only setting (see gotcha #7).
railway up deploys the LOCAL directory, not GitHub. It's the fastest way
to a first live build, but it's a manual upload — the GitHub watch still needs
gotcha #9. Use it to validate the pipeline, then enable GitHub auto-deploy.
- Verify, don't trust. A returning
up only means "build queued." Poll
deployment list to a terminal SUCCESS, then curl the health endpoint.
Output
Report: the live URL + health result, every service + its ID, what auto-deploys,
and the precise USER hand-off list (GitHub App auth, any external OAuth/KMS/S3
accounts with exact callback URLs/values, the cron config-file click).
1---2name: deploy-to-railway3description: Use when deploying a project to Railway from scratch — provisioning a Postgres database, one or more app services (a full-stack monolith OR separate frontend/backend), an S3-compatible bucket, sharing credentials + env vars between services, wiring GitHub auto-deploy on merge to main, and adding auth (Google SSO or Cloudflare Zero Trust in front). Triggers on "deploy this to Railway", "get it live on Railway", "set up Railway with a database + auto deploy", "split front/back end services on Railway", "add an S3 bucket on Railway", "share env vars between Railway services". Carries the hard-won gotchas (NIXPACKS Node version, npm-version lockfile skew, RLS-safe DATABASE_URL, DB-role password rotation, GitHub App authorization) that turn a 6-hour debug into a 20-minute deploy.4---56# Deploy a project to Railway (live, auto-deploying, secure)78This is a **companion to the official `railway:use-railway` skill** — load that9too; it owns the CLI/MCP mechanics (auth, `railway up`, logs, the GraphQL10fallback). THIS skill is the opinionated end-to-end playbook + the gotcha list11that the generic skill doesn't have. When they disagree, the generic skill wins12on a command's exact flags; this skill wins on the *sequence and the traps*.1314## The model (read once)1516- **Workspace** → billing scope. **Project** → one app + its DB(s). **Service**17 → one deployable (app, DB, or bucket). **Environment** → `production` etc.18- Railway gives every service a **private** address (`<svc>.railway.internal`)19 and, on request, a **public** domain. Services in the same project reach each20 other over the private network — keep databases private.21- **Reference variables** `${{ OtherService.VAR }}` are how you share22 credentials/values between services. Same-service self-refs work too:23 `${{ MY_OWN_VAR }}`.2425## Decide the service topology FIRST2627| Project shape | Services to create |28|---|---|29| **Full-stack framework** (Next.js, Remix, SvelteKit, Rails, Django+templates) | **ONE** app service. The framework serves UI *and* API from one process. Do NOT split it — two services for one codebase is wrong and leaves a dead/broken half. |30| **Truly separate FE + BE** (React/Vue SPA + a separate Node/Python/Go API) | **TWO** services from the same or two repos. FE gets the public domain; BE stays private (or its own domain); FE reaches BE via `${{ backend.RAILWAY_PRIVATE_DOMAIN }}` or a public URL. Set `NEXT_PUBLIC_API_URL`/`VITE_API_URL` on the FE to the BE's URL. **If both live in ONE repo (monorepo), each service needs its own Root Directory** — see the monorepo note below. |31| **+ background jobs / cron** | A **separate** service (same repo) with its own config file (cron schedule + start command + `restartPolicy NEVER`). |32| **+ object storage** | A **bucket** (project-level), creds shared to the services that need it. |3334If the user says "separate front and back end" but the repo is a full-stack35framework, **tell them it's one service** and why — build the right thing.3637**Monorepo (FE + BE, or many packages, in one repo):** each service needs a38**Root Directory** set to its package path (e.g. `/frontend`, `/backend`) — a39**dashboard/MCP-only** setting (like the cron Config File Path below), so hand the40user the click or set it via `update_service`. ⚠️ A `railway.json`/`railway.toml`41config file does NOT follow Root Directory — give it an ABSOLUTE path (e.g.42`/backend/railway.toml`), not one relative to the root dir. Shortcut: importing43the repo via `railway.com/new` can auto-stage one service per package for44pnpm/npm/yarn/bun workspaces. Pair this with per-service Watch Paths (gotcha #9)45so a push only rebuilds the service it touched.4647## The deploy sequence (CLI; the MCP is often unauthenticated — verify and fall back to CLI)4849Prefix Railway CLI calls with `RAILWAY_CALLER=skill:use-railway@<v>` and a stable50`RAILWAY_AGENT_SESSION`. **Background long commands** (`add --database`, `up`) and51**verify with `railway service list`, not the command's stdout** — Railway's CLI52frequently prints `Unauthorized`/errors on a step that actually succeeded.53541. **Auth + workspace**: `railway whoami --json` (lists workspaces). If it fails,55 the user runs `railway login` (their browser).562. **Project**: `railway init --name <project> --workspace <id> --json` (links cwd).573. **Postgres**: `railway add --database postgres --json`. ⚠️ It may print58 `Unauthorized` yet still create it — **check `railway service list`**. If you59 ran it twice, you'll have duplicates; delete extras:60 `railway service delete --service <name> --yes`.614. **App service from GitHub**:62 `railway add --service <name> --repo <owner/repo> --branch main --json`.635. **Env vars** (see the var recipe below) — set with `--skip-deploys`, all at once:64 `railway variable --service <name> --skip-deploys --set 'K=V' --set 'K2=V2' …`65 Quote `${{…}}` references in single quotes so the shell doesn't expand them.666. **Domain**: `railway domain --service <name> --json` → returns the public URL.67 Set `APP_ORIGIN` to it (the app needs its own public origin for OAuth68 callbacks, links, WebAuthn RP id).697. **Deploy + VERIFY**: never report success on `up` returning. Poll70 `railway deployment list --service <name> --limit 1 --json` until `status` is71 `SUCCESS` (deployed) or `FAILED`/`CRASHED` (triage logs). Then `curl` the72 public `/health` (or `/api/health`) endpoint to confirm it's actually serving.73 **Set the service's `healthcheckPath`** (e.g. `/health`, `/api/health`) so74 Railway itself gates the traffic cutover on a 200 before going live — without75 it, a broken new deploy silently replaces a working one. The default76 healthcheck timeout is **300s**; raise it via `RAILWAY_HEALTHCHECK_TIMEOUT_SEC`77 for slow first boots/migrations. The check runs **once at deploy time, NOT78 continuously** — a passing deploy is not uptime monitoring (use Uptime Kuma or79 similar for that). Railway's healthcheck hits the app over the injected `PORT`80 from `healthcheck.railway.app`, so gotchas #2 and #3 apply.818. **Cron/worker service**: `railway add --service <name> --repo … --branch main`,82 set its env, then set its **Config File Path** to the cron config (e.g.83 `/railway.cron.json`) — that file carries `deploy.cronSchedule` +84 `deploy.startCommand` + `restartPolicy NEVER`. ⚠️ The config-file-path is a85 **dashboard/MCP-only** setting; the CLI can't set it. Tell the user the one86 click, or set it via the `update_service` MCP tool / GraphQL if the MCP is authed.8788## The env-var recipe (Postgres + RLS-safe app)8990The #1 production security trap: **the app must connect as a non-superuser DB role91with RLS enforced, NOT the Railway default `postgres` superuser** (superusers92BYPASS row-level security → cross-tenant data leak).9394- **Migrations** run as the OWNER (create roles, RLS, schema). Reference the95 Postgres service's owner creds:96 `PGHOST=${{ Postgres.PGHOST }}`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`97 (all `${{ Postgres.* }}`), plus the target DB name your migrator expects.98- **App runtime** connects as the **app role**, via a CUSTOM `DATABASE_URL` — NOT99 `${{ Postgres.DATABASE_URL }}` (that's the superuser):100 `DATABASE_URL=postgresql://<approle>:<APP_DB_PASSWORD>@${{ Postgres.RAILWAY_PRIVATE_DOMAIN }}:5432/<db>`101- **Rotate the app role's password.** Most apps seed the role with a weak dev102 password. Rotate it to a strong `APP_DB_PASSWORD` (32-byte hex). The cleanest103 spot is the migrator's pre-deploy step: after migrating, run104 `ALTER ROLE <approle> PASSWORD '<APP_DB_PASSWORD>'` (literal-escaped — no bind105 params for passwords; double single-quotes). Then the app's `DATABASE_URL`106 (with `APP_DB_PASSWORD`) connects on the first boot.107- Generate secrets locally, don't hardcode: `node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"`.108- Set `APP_ORIGIN`, any product-name/brand var, and a `CRON_SECRET` if there's a109 cron→app POST.110111## Object storage (S3-compatible bucket) + sharing creds1121131. `railway bucket create --name <bucket>` (project-level).1142. `railway bucket credentials --bucket <bucket> --json` → endpoint, access key,115 secret, region.1163. Share to the services that need it as variables (or references):117 `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET`,118 `S3_REGION`. If the bucket exposes them as service vars, prefer119 `${{ <bucket>.* }}` references so a rotation propagates.120121## Auth in front of the app122123- **Google SSO (app-native)**: the user creates a Google Cloud **OAuth client124 (Web)**; the authorized redirect URI is `${APP_ORIGIN}/api/auth/google/callback`125 (or whatever the app's callback route is — copy the EXACT path from the app, a126 wrong path is the classic launch-day `redirect_uri_mismatch`). They set127 `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET`. You cannot create the OAuth client128 for them — hand off with the exact callback URL filled in.129- **Cloudflare Zero Trust (gateway auth, no app code)**: put a Cloudflare Access130 policy in front of the public domain. The user: add the domain to Cloudflare131 (DNS), create an **Access application** for it, pick an identity provider132 (Google/GitHub/OTP), and write an allow policy (e.g. emails @company). Access133 then challenges every request before it reaches Railway — good when the app has134 no SSO of its own, or for locking a staging/admin URL. (Railway custom domain +135 Cloudflare proxied DNS is the wiring; the Access app is the gate.)136137## GOTCHAS (the time-savers — check these the moment a build misbehaves)1381391. **Build "schedules" then FAILS with ZERO output → Node version.** NIXPACKS140 can't provision bleeding-edge Node (e.g. 24, 25). If `.nvmrc`/`engines` pins a141 too-new major, the Nix env fails to evaluate before any build log prints. Fix:142 set `NIXPACKS_NODE_VERSION=22` (or another LTS the engines allow). This is the143 single highest-value gotcha — a no-output build failure is almost always this.1442. **Deploy goes SUCCESS but the app healthcheck-fails / crash-loops → it's145 binding `127.0.0.1` or a hardcoded port.** Railway injects `PORT` and routes146 traffic to it; an app listening on `localhost`/`127.0.0.1` or a baked-in port147 answers nobody, and Railway reports "service unavailable" despite a green build.148 This is the #1 "deployed but not serving" failure (vendor- and cross-platform-149 confirmed), and a different root cause than the Nixpacks gotchas above. The150 server MUST listen on `0.0.0.0` AND read the injected `PORT`:151 `app.listen(process.env.PORT, '0.0.0.0')`, `uvicorn --host 0.0.0.0 --port $PORT`,152 `gunicorn -b 0.0.0.0:$PORT`. **Dockerfile twist:** exec-form `CMD ["node","server.js"]`153 (JSON array) does NOT expand `$PORT` — use shell-form `CMD node server.js` /154 `CMD gunicorn -b 0.0.0.0:${PORT:-8000} app:app` so the variable interpolates.1553. **Host-allowlist apps reject Railway's healthcheck → 400 / "service156 unavailable" despite a healthy app.** Railway's healthcheck request originates157 from hostname `healthcheck.railway.app`. Apps with a host allowlist — Django158 `ALLOWED_HOSTS`, Rails `config.hosts`, Express `helmet`, some reverse proxies —159 reject that `Host` header and the deploy fails even though the app is fine. Fix:160 add `healthcheck.railway.app` to the allowlist (or scope `*` to the health161 route). Looks like a Railway bug; it's an app-config issue.1624. **`npm ci` "Missing @esbuild/<platform>@x from lock file" → npm version skew.**163 npm 11 (ships with Node 24+) prunes optional per-platform packages from the164 lockfile that npm 10 (ships with Node 22) strictly requires. If you pin Node 22165 on Railway but the lockfile was written by npm 11, `npm ci` fails. Fix:166 regenerate the lockfile with the matching npm —167 `npx -y npm@10 install --package-lock-only` — commit it (it only ADDS platform168 entries, no version changes), verify with `npx npm@10 ci --dry-run`.1695. **"I set X but Railway ignores it" → build-method precedence.** Any `Dockerfile`170 in the repo SILENTLY overrides Nixpacks and the `DOCKER_IMAGE` setting; a171 `railway.json`/`railway.toml` `deploy.startCommand` SILENTLY overrides the172 Dockerfile `CMD`. Before debugging Nixpacks, check for a stray `Dockerfile` or a173 config `startCommand` — that's a distinct failure class from the Node/npm174 gotchas above.1756. **`Unauthorized` that actually succeeded.** Railway's CLI/MCP throws auth-y176 errors on `add --database`/provisioning that nonetheless create the resource.177 Always re-verify with `railway service list` before retrying (retrying makes178 duplicates).1797. **MCP unauthenticated even though the CLI is authed.** The Railway MCP server180 has its own token; `railway --help` shows an "Agent tooling" health section. If181 the MCP is `✗`, just use the CLI (don't block on `railway setup agent`, which182 needs a tool restart). Some settings (a service's Config File Path, Root183 Directory, Watch Paths) are only reachable via MCP/dashboard, not the CLI.1848. **RLS bypass via DATABASE_URL.** Covered above — the app's `DATABASE_URL` must185 be the app-role URL, never `${{ Postgres.DATABASE_URL }}` (superuser).1869. **GitHub auto-deploy-on-merge needs the Railway GitHub App authorized on the187 repo.** Connecting a repo by name (`add --repo`) sets the metadata, but the188 first build won't auto-fire and merges won't deploy until the user installs/189 authorizes Railway's GitHub App for that repo (railway.com → the service →190 Settings → Source, or Account → Integrations → GitHub). Until then, deploy with191 `railway up` (deploys the local checkout) to get it live, and hand off the192 one-time App authorization to enable the watch. **Monorepo with multiple193 services:** set per-service **Watch Paths** (gitignore-style globs, e.g.194 `/backend/**`) — without them, every merge to main rebuilds and redeploys EVERY195 service in the project, not just the one that changed. Watch Paths are a196 dashboard/MCP-only setting (see gotcha #7).19710. **`railway up` deploys the LOCAL directory**, not GitHub. It's the fastest way198 to a first live build, but it's a manual upload — the GitHub *watch* still needs199 gotcha #9. Use it to validate the pipeline, then enable GitHub auto-deploy.20011. **Verify, don't trust.** A returning `up` only means "build queued." Poll201 `deployment list` to a terminal `SUCCESS`, then `curl` the health endpoint.202203## Output204205Report: the live URL + health result, every service + its ID, what auto-deploys,206and the precise USER hand-off list (GitHub App auth, any external OAuth/KMS/S3207accounts with exact callback URLs/values, the cron config-file click).