Architect
Standards
ReferenceNormative

Railway

Deploy from a committed Dockerfile with railway.json beside it, set every runtime value through the CLI, and know where the build context actually starts.

Verified 2026-08-04 · Railway CLI 5.27.0, Postgres 18. Railway docs on Dockerfiles, monorepos and config-as-code read 2026-08-03.

Deploy from a Dockerfile committed to the repository, with railway.json beside it for build and deploy settings, and set every runtime value as a service variable via the CLI.

Do not reach for railway config (the .railway/railway.ts config-as-code): it needs the Railway TypeScript SDK and a Node toolchain, so it is dead weight in a JVM or Go repository. railway.json covers the same build and deploy fields with no dependency.

Decisions that stuck

  • railway.json next to the Dockerfile, not at the repository root. A root config silently applies to every service built from the repository, so the second service inherits the first one's Dockerfile. One config per service directory keeps a monorepo honest. Railway picks it up alongside a Dockerfile declared at that path.
  • Assemble JDBC URLs from the PG* reference variables${{svc.PGHOST}}, ${{svc.PGPORT}}, ${{svc.PGDATABASE}}, ${{svc.PGUSER}}, ${{svc.PGPASSWORD}}. Reference syntax tolerates a hyphen in the service name (${{identity-db.PGHOST}}). Single-quote it in a shell — ${ is parameter expansion.
  • --skip-deploys while setting a batch of variables, then let one push trigger a single build.
  • RAILWAY_DOCKERFILE_PATH as a service variable picks the Dockerfile with the repository root as build context — several services from one repository, no dashboard Root Directory needed (verified on a 3-service deployment, 2026-08-04). This is the inverse of the subdirectory-context trap below, which applies when the Dockerfile is found at the service's root directory.
  • Non-interactive service creation from a repository works: railway add -s <name> -r <org>/<repo> --branch main -v "K=V" …. Domains too: railway domain <custom> --service X and railway domain delete <domain> --service X --yes.

Generating secrets without printing them

railway variables --set "PASSWORD=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)" >/dev/null
railway variables --kv | cut -d= -f1     # verify by listing keys only

The value never enters the transcript; read it back from the dashboard if it is needed. See secrets.

Traps

A Dockerfile builds with its own directory as the build context, not the repository root. A Dockerfile at infra/identity/Dockerfile resolves COPY themes/ … against infra/identity/. Rewriting those paths to be repo-relative — the natural assumption — fails the build with no useful message. Cost about 20 minutes and a failed deployment.

Corollary: an image needing the whole repository (a multi-module Maven build) cannot live in a subdirectory without setting the service's Root Directory, which is a dashboard-only setting — no CLI, no config file.

  • DATABASE_URL is a postgresql:// URL. Anything wanting JDBC — Keycloak, Spring, Flyway — cannot parse it. Build jdbc:postgresql://host:port/db from the PG* parts instead.
  • railway logs --build returns the last successful build, not the failed one, and --deployment <id> cannot be combined with --build. To diagnose a failed build, read the deployed serviceManifest out of railway status --json and compare it against what you expected.
  • railway variable delete takes no --yes and no --skip-deploys — passing either is a usage error and the command silently does nothing useful. Just railway variable delete KEY --service X.
  • Volumes mount root-owned, and RAILWAY_RUN_UID=1000 does NOT chown them — a non-root image gets permission denied writing into its own volume. The working escape is RAILWAY_RUN_UID=0, so the process runs as root. Verified on a scratch-based image, 2026-08-04.
  • railway volume files global flags go BEFORE the subcommand: railway volume files --volume <name> download <remote> <local>. After the subcommand they are rejected. download is the transcript-safe way to move a generated secret off a volume — pipe it into a variable set, never print it.
  • A crash-looping init-style service re-runs its init on every restart. Dropping a database "under" a crashed deployment gets it re-poisoned by the next backoff restart. Stop the service (railway down) before the destructive reset, then deploy fresh. Also seen: railway up right after railway down can land as SKIPPED; railway redeploy of the skipped deployment runs it.
  • A git push authored by a GitHub account the workspace does not recognise parks the deployment in NEEDS_APPROVAL — it looks stuck, not gated. railway up --service <svc> -p <project-id> -e <env> from the repository directory deploys the same content immediately, because the CLI user is authorised. The pending GitHub deploy still needs a dashboard approve or dismiss. See git conventions.
  • Point the healthcheck at something that proves the app initialised, not at /. For an identity server, the realm's /.well-known/openid-configuration — it only answers once the realm import succeeded, so a broken import fails the deploy instead of serving an empty realm. Management-port health endpoints are unreachable by Railway's probe.

Open questions

  • Whether the service Root Directory can be set through the public GraphQL API rather than the dashboard. Not investigated; the subdirectory-context behaviour made it unnecessary.
  • How to make a realm or schema definition re-apply on redeploy. Keycloak's --import-realm skips an existing realm, so configuration baked into an image is applied once, against an empty database. Same shape as any seed-on-first-boot mechanism — decide it before anyone creates real data.

On this page