Railway
One file, .railway/railway.ts, describes the whole Railway project: the api and the worker from the release image, Cerbos and Postgres with pgvector built from your deployment repository, each container’s identity as a sealed variable, and the public domain on the api only. This guide ends with pbmb admin status passing against the new address.
The file uses Railway’s Infrastructure as Code. Railway’s older per-service railway.json is deprecated: new services cannot use it, and existing files stop being read on 2026-12-01.
The release’s image is ghcr.io/enum-studio/powerbi-metrics-book:<version> and its CLI the powerbi-metrics-book package on PyPI, at the same version. The file pins the image by digest.
What you need
Section titled “What you need”- A person with a Railway account, who connects Railway’s GitHub app to the deployment repository.
- The Railway CLI, 5.42.1 or newer, signed in with
railway login; Node 24 withnpm;pbmbat the release’s version; Docker, forpbmb check;age-keygenfrom age;jq;git, forgit ls-remotein step 1. - A person who can add DNS records for the address people will open.
1. The deployment repository
Section titled “1. The deployment repository”PBMB_VERSION=0.1.0uv tool install "powerbi-metrics-book==$PBMB_VERSION"pbmb init ./pbmb-deployment --public-base-url https://metrics.example.comcd ./pbmb-deploymentCOMMIT="$(git ls-remote https://github.com/enum-studio/powerbi-metrics-book "refs/tags/v$PBMB_VERSION" "refs/tags/v$PBMB_VERSION^{}" | tail -n 1 | cut -f 1)"echo "$COMMIT"base="https://raw.githubusercontent.com/enum-studio/powerbi-metrics-book/$COMMIT/deploy"mkdir -p .railway railwaycurl -fsSL "$base/railway/railway.ts" -o .railway/railway.tscurl -fsSL "$base/railway/postgres.Dockerfile" -o railway/postgres.Dockerfilecurl -fsSLO "$base/cerbos.Dockerfile"curl -fsSLO "$base/railway/package.json" && curl -fsSLO "$base/railway/package-lock.json"npm ciprintf 'node_modules/\n*.key\n' >> .gitignoregit ls-remote asks GitHub which commit the tag names, and the files come from that commit, so they cannot change under you if the tag moves. To check it, compare the printed commit with the one on the release’s GitHub page. npm ci installs the railway package at the version the lockfile pins, which railway config needs to read the file. The *.key line keeps any age key out of git.
Edit the four values at the top of .railway/railway.ts:
| Value | What |
|---|---|
IMAGE |
the image line in compose.yml, from grep 'image: &image' compose.yml |
PUBLIC_HOST |
metrics.example.com, the host of PUBLIC_BASE_URL |
REPOSITORY, BRANCH |
this repository on GitHub, such as example-org/pbmb-deployment, and its branch |
Then check, commit and push. .env stays out of git:
pbmb check .git init && git add . && git commit -m "Add the Power BI Metrics Book deployment"Push it to a private GitHub repository that Railway’s GitHub app can read.
2. The identities
Section titled “2. The identities”Each container decrypts its secrets with an age identity. On Railway each is a sealed variable: Railway hands it to the container and never shows it again, in the dashboard or through the API (sealed variables). Make both in a new folder outside the repository, readable by you only:
keys="$(mktemp -d)"(umask 077 && age-keygen -o "$keys/api.key" && age-keygen -o "$keys/worker.key")echo "$keys"Never write these files into the deployment repository.
3. The project and its shared variables
Section titled “3. The project and its shared variables”A person creates an empty project in Railway’s dashboard. Link this folder to it:
railway linkA person opens Project Settings, Shared Variables, and adds eight values. Six come from .env: POSTGRES_PASSWORD, MIGRATOR_PASSWORD, API_RW_PASSWORD, WORKER_RW_PASSWORD, AUDIT_WRITER_PASSWORD and AUTH_SECRET. Two come from the folder of step 2: API_AGE_IDENTITY and WORKER_AGE_IDENTITY, each the AGE-SECRET-KEY-1... line of api.key or worker.key. Then the person seals each of the eight, in its menu. Agents do not read .env or the keys, so this is a person’s step.
The person then moves the two key files to the company’s password manager and deletes the folder: they are the only copy outside Railway, and every secret in the database is encrypted to them.
The file never holds a value. Each service gets only the shared variables it names: Postgres the five passwords, the api its own password, the migrator’s, the audit writer’s, AUTH_SECRET and its identity, and the worker its own password, the audit writer’s and its identity. The database URLs are built from them at deploy with Railway’s reference syntax, such as ${{shared.API_RW_PASSWORD}} and ${{postgres.RAILWAY_PRIVATE_DOMAIN}} (variables).
4. Plan and apply
Section titled “4. Plan and apply”railway config planrailway config applyThe plan lists five services and one volume to create. Apply asks you to confirm it. Postgres and Cerbos build from the repository: Postgres is pgvector’s image, pinned by digest, with postgres/init/ inside, which makes the roles, the database and the extensions on its first start; Cerbos holds cerbos/config.yaml, the release’s default policies and your access/policies/, and runs as user 10001. The fifth, edge-check, serves step 5 only.
The services reach each other over Railway’s private network, as postgres.railway.internal and cerbos.railway.internal. Railway gives every service a way out to the internet, so Postgres and Cerbos are not kept off it as on a VM.
5. The edge check
Section titled “5. The edge check”Sign-in rate limits count by the client’s address. Railway’s edge sets X-Real-IP to it (public networking specs), but its pages do not say it replaces an X-Real-IP the client sends. If it does not, anyone could pick the address their sign-ins count against. So the api does not read the header yet: first test the edge with edge-check, a pinned Caddy that answers with the X-Real-IP it receives.
railway domain --service edge-check --port 8080curl -sS -H 'X-Real-IP: 192.0.2.1' https://<the domain it printed>/echocurl -sS https://ifconfig.me- The two addresses match, your own public address: the edge replaced your header. In
.railway/railway.tssetEDGE_CHECK = falseandCLIENT_ADDRESS_FROM_EDGE = true, then commit, push, plan and apply. The plan deletesedge-checkand setsCLIENT_ADDRESS_HEADER=x-real-ipon theapi. - It prints
192.0.2.1: the edge passed your header on. SetEDGE_CHECK = falseonly, and apply. Every sign-in then counts against the edge’s address and all share one limit: Railway names no address range its edge connects from, so there is nothing forTRUSTED_PROXYto trust.
Run the check again, with EDGE_CHECK = true, after any change to Railway networking: a new domain, a proxy in front, or a change in Railway’s changelog to its edge.
Never set TRUSTED_PROXY here: the api refuses to start with both. Under CLIENT_ADDRESS_HEADER the api also refuses to hold a service account to source CIDRs.
6. The address
Section titled “6. The address”On the api service’s Settings, under Networking, Railway shows two DNS records for metrics.example.com: a CNAME and a TXT record. A person adds both exactly as shown (working with domains). Railway issues the certificate, usually within an hour.
7. The setup link and the check
Section titled “7. The setup link and the check”railway ssh --service api -- server setup-linkThe link is printed by the command, not in the log. It works once, for 24 hours. Open it and follow the Set up part. Then:
pbmb login --url https://metrics.example.compbmb admin statuspbmb login prints a code; a person opens the address it names in a browser, enters the code and signs in. pbmb admin status answers with no failing check.
Once signed in, the api itself reports the address it counts you by:
jq -r '"Authorization: Bearer " + .access_token' "${XDG_CONFIG_HOME:-$HOME/.config}/pbmb/credentials.json" | curl -sS -H @- -H 'X-Real-IP: 192.0.2.1' https://metrics.example.com/api/v1/me | jq -r .client_addressThe token goes to curl on its standard input, never on a command line. With CLIENT_ADDRESS_FROM_EDGE = true it prints your own public address, as step 5 did.
What Railway does not offer
Section titled “What Railway does not offer”A service on Railway runs with the platform’s own settings: Railway has no setting for a read-only root file system or for dropping Linux capabilities, both of which compose.yml sets on a VM. Postgres runs as the pgvector image starts it, as root until it hands over to its own postgres user.
Identity volumes instead
Section titled “Identity volumes instead”With IDENTITY_VOLUMES = true in the file, each identity lives in a volume, and server rotate-identity works. The cost: Railway mounts a volume as root, and its volumes page gives one answer for an image that runs as another user, RAILWAY_RUN_UID=0, which the file then sets. The api and the worker then run as root, not as user 10001. Leave out API_AGE_IDENTITY and WORKER_AGE_IDENTITY in step 3.
Backups
Section titled “Backups”A person turns on daily and weekly backups on the pgdata volume, in the Postgres service’s Backups tab (backups). Railway keeps daily backups 6 days and weekly ones 27, inside the same project: someone who can delete the project can delete them too. These backups keep everything in the database, query results included, which are at most 72 hours old when taken.
The identities are in the password manager from step 3, apart from the database. Keep them there, and add each new one after a rotation.
Upgrading
Section titled “Upgrading”Run pbmb init of the new version into a new folder made with mktemp -d, as in the VM guide’s Upgrading section. Copy its image line into IMAGE and its postgres/ and cerbos/ into this repository, fetch the new files from step 1, commit, push, then railway config plan and railway config apply.
Rotating an identity
Section titled “Rotating an identity”With sealed identities, server rotate-identity refuses: make a new key as in step 2, put it in the shared variable, and enter that container’s secrets again in the admin section. With identity volumes:
railway ssh --service api -- server rotate-identityrailway restart --service apikeys="$(mktemp -d)"railway volume files --volume api-identity download /api.key "$keys/api.key"The same works for the worker. The last two commands save the new identity in a new folder outside the repository; move it to the password manager.