| 1 | # Deploying to Railway |
| 2 | |
| 3 | Ghost is a Bun/Turborepo monorepo with two long-running services plus two pieces |
| 4 | of infrastructure: |
| 5 | |
| 6 | | Piece | What it is | On Railway | |
| 7 | | ---------------- | ---------------------------- | |
| 8 | | `apps/api` | NestJS + git smart-HTTP | Service, |
| 9 | | `apps/web` | Next.js | Service, |
| 10 | | Postgres | Everything except git objects | Railway Postgres database | |
| 11 | | S3 bucket | The git object log (WAL) | External: Cloudflare R2, Tigris, MinIO | |
| 12 | |
| 13 | All of it is declared in `.railway/railway.ts`. |
| 14 | |
| 15 | Railway has no S3 product, so the bucket comes from elsewhere. Anything |
| 16 | S3-compatible works — the client uses path-style addressing and region `auto`. |
| 17 | |
| 18 | Repository working copies are a **cache** under the API's ` |
| 19 | the S3 log on demand, so no volume is required. Attaching one to `/tmp` only |
| 20 | saves re-materialisation after a restart. |
| 21 | |
| 22 | ## 1. Declare the infrastructure |
| 23 | |
| 24 | Everything lives in `.railway/railway.ts` — Railway's Infrastructure as Code. |
| 25 | (`railway.json` / `railway.toml` config-as-code is deprecated and stops being |
| 26 | read on 2026-12-01.) The file declares the Postgres database and both services, |
| 27 | including which Dockerfile each one builds and the migration step: |
| 28 | |
| 29 | ```ts |
| 30 | const api = service |
| 31 | source: github(REPO |
| 32 | build: { builder: " |
| 33 | preDeploy: "node packages/db/dist/migrate.js", |
| 34 | healthcheck: "/api", |
| 35 | domains: [API_DOMAIN], |
| 36 | env: { DATABASE_URL: db |
| 37 | }); |
| 38 | ``` |
| 39 | |
| 40 | Open it and change the two things that are yours: `REPO` and `ROOT_DOMAIN`. |
| 41 | |
| 42 | Secrets are declared as `preserve()`, which means "keep whatever is already set |
| 43 | on the service". Set those once by hand, and the file never carries a secret. |
| 44 | |
| 45 | ## 2. Apply it |
| 46 | |
| 47 | ```sh |
| 48 | npm install -g @railway/cli |
| 49 | railway login |
| 50 | railway link # create or pick the project |
| 51 | railway config plan # |
| 52 | railway config apply |
| 53 | ``` |
| 54 | |
| 55 | Set the secrets before or right after the first apply, on the `api` service: |
| 56 | |
| 57 | ```sh |
| 58 | railway variables --service api |
| 59 | --set BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \ |
| 60 | --set S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com |
| 61 | --set S3_ACCESS_KEY_ID=... \ |
| 62 | --set S3_SECRET_ACCESS_KEY=... |
| 63 | ``` |
| 64 | |
| 65 | Custom domains are the one thing configuration cannot create — `railway config |
| 66 | apply` rejects them with *"Custom-domain registration is not supported by |
| 67 | Railway configuration"*. Add both in the dashboard, then pull what Railway now |
| 68 | knows: |
| 69 | |
| 70 | ```sh |
| 71 | railway config pull |
| 72 | ``` |
| 73 | |
| 74 | `railway config plan` on every change, `railway config apply` to commit it. |
| 75 | |
| 76 | ## 3. What the file already sets |
| 77 | |
| 78 | ### `api |
| 79 | |
| 80 | | Variable | Source | |
| 81 | | ----------------------- | ------------------------------------------------------- | |
| 82 | | `DATABASE_URL` | referenced from the Postgres resource | |
| 83 | | `BETTER_AUTH_URL` | `https://<API_DOMAIN> |
| 84 | | `AUTH_TRUSTED_ORIGINS` | `https://<WEB_DOMAIN> |
| 85 | | `AUTH_COOKIE_DOMAIN` | `.<ROOT_DOMAIN> |
| 86 | | `S3_BUCKET` | `ghost |
| 87 | | `BETTER_AUTH_SECRET`, `S3_ENDPOINT`, |
| 88 | |
| 89 | `PORT` is injected by Railway and the server prefers it over `API_PORT`. |
| 90 | |
| 91 | ### `web |
| 92 | |
| 93 | | Variable | Source | |
| 94 | | ----------------------- | ------------------------------- | |
| 95 | | `NEXT_PUBLIC_API_URL` | `https://<API_DOMAIN> |
| 96 | |
| 97 | `NEXT_PUBLIC_*` is inlined at **build** |
| 98 | to Dockerfile builds only through `ARG`, which `apps/web/Dockerfile` declares — |
| 99 | so the value is picked up automatically, but changing it needs a rebuild, not a |
| 100 | restart. |
| 101 | |
| 102 | ## 4. Domains and cookies |
| 103 | |
| 104 | The browser talks to the API directly, so sessions are only cross-origin-safe |
| 105 | when both services sit under one registrable domain: |
| 106 | |
| 107 | - `web` → `ghost.example.com` |
| 108 | - `api` → `api.ghost.example.com` |
| 109 | - `AUTH_COOKIE_DOMAIN` → `.example.com` |
| 110 | |
| 111 | `*.up.railway.app` is on the Public Suffix List, so a cookie cannot be shared |
| 112 | across two generated Railway subdomains. **Sign-in will not work on the default |
| 113 | domains.** Add both domains in the dashboard (configuration cannot register |
| 114 | them) and point their DNS at Railway before testing auth. The `ROOT_DOMAIN` |
| 115 | constant in `.railway/railway.ts` only feeds the URL variables. |
| 116 | |
| 117 | Clone URLs follow the API domain: |
| 118 | |
| 119 | ```sh |
| 120 | git clone https://api.ghost.example.com/<user |
| 121 | ``` |
| 122 | |
| 123 | ## 5. Migrations |
| 124 | |
| 125 | `.railway/railway.ts` runs `node packages/db/dist/migrate.js` as the API's |
| 126 | pre-deploy command, which applies `packages/db/drizzle/*.sql` with the same connection |
| 127 | string as the app. A failed migration fails the deploy and the previous version |
| 128 | stays up. |
| 129 | |
| 130 | ## Building the images locally |
| 131 | |
| 132 | ```sh |
| 133 | docker build -f apps/api/Dockerfile |
| 134 | docker build -f apps/web/Dockerfile |
| 135 | --build-arg NEXT_PUBLIC_API_URL=https://api.ghost.example.com \ |
| 136 | -t ghost-web . |
| 137 | ``` |
| 138 | |
| 139 | The API image carries the `git` binary — every fetch and push shells out to it. |
| 140 | |
| 141 | The web image starts with `HOSTNAME=0.0.0.0`. Next.js' standalone server binds |
| 142 | to whatever `$HOSTNAME` holds, and container runtimes set that to the container |
| 143 | id — a single interface, which answers a published Docker port but refuses the |
| 144 | Railway proxy. The `CMD` sets it at boot so a runtime-injected `HOSTNAME` cannot |
| 145 | win. |