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.jsonnext 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-deployswhile setting a batch of variables, then let one push trigger a single build.RAILWAY_DOCKERFILE_PATHas 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 Xandrailway 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 onlyThe 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_URLis apostgresql://URL. Anything wanting JDBC — Keycloak, Spring, Flyway — cannot parse it. Buildjdbc:postgresql://host:port/dbfrom thePG*parts instead.railway logs --buildreturns the last successful build, not the failed one, and--deployment <id>cannot be combined with--build. To diagnose a failed build, read the deployedserviceManifestout ofrailway status --jsonand compare it against what you expected.railway variable deletetakes no--yesand no--skip-deploys— passing either is a usage error and the command silently does nothing useful. Justrailway variable delete KEY --service X.- Volumes mount root-owned, and
RAILWAY_RUN_UID=1000does NOT chown them — a non-root image getspermission deniedwriting into its own volume. The working escape isRAILWAY_RUN_UID=0, so the process runs as root. Verified on a scratch-based image, 2026-08-04. railway volume filesglobal flags go BEFORE the subcommand:railway volume files --volume <name> download <remote> <local>. After the subcommand they are rejected.downloadis 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 upright afterrailway downcan land asSKIPPED;railway redeployof 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-realmskips 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.