| 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 | Railway has no S3 product, so the bucket comes from elsewhere. Anything |
| 14 | S3-compatible works — the client uses path-style addressing and region `auto`. |
| 15 | |
| 16 | Repository working copies are a **cache** under the API's ` |
| 17 | the S3 log on demand, so no volume is required. Attaching one to `/tmp` only |
| 18 | saves re-materialisation after a restart. |
| 19 | |
| 20 | ## 1. Create the project |
| 21 | |
| 22 | 1. New project → **Deploy from GitHub repo**, pick this repository. |
| 23 | 2. Add a **Postgres** database to the project. |
| 24 | 3. Delete the service Railway guesses for you, then add two services from the |
| 25 | same repo: name them `api` and `web`. |
| 26 | |
| 27 | ## 2. Point each service at its Dockerfile |
| 28 | |
| 29 | Both services build from the **repository root** (they need the ` |
| 30 | workspace and `bun.lock`), so leave *Root Directory |
| 31 | service, *Settings → Config-as-code → Config file path*: |
| 32 | |
| 33 | - `api` → `railway.api.json` |
| 34 | - `web` → `railway.web.json` |
| 35 | |
| 36 | Those files pin the builder, the Dockerfile path, the start command, the health |
| 37 | check, and — for the API — the pre-deploy migration step. |
| 38 | |
| 39 | ## 3. Environment variables |
| 40 | |
| 41 | ### `api |
| 42 | |
| 43 | | Variable | Value | |
| 44 | | ----------------------- | -------------------------------------------------------------- | |
| 45 | | `DATABASE_URL` | `${{Postgres.DATABASE_URL}} |
| 46 | | `BETTER_AUTH_SECRET` | 32+ random chars (`openssl rand -base64 32 |
| 47 | | `BETTER_AUTH_URL` | Public API URL, e.g. `https://api.ghost.example.com |
| 48 | | `AUTH_TRUSTED_ORIGINS` | Public web URL, e.g. `https://ghost.example.com |
| 49 | | `AUTH_COOKIE_DOMAIN` | `.example.com |
| 50 | | `S3_ENDPOINT` | e.g. `https://<account>.r2.cloudflarestorage.com |
| 51 | | `S3_ACCESS_KEY_ID` | from the bucket provider | |
| 52 | | `S3_SECRET_ACCESS_KEY` | from the bucket provider | |
| 53 | | `S3_BUCKET` | bucket name — ** |
| 54 | |
| 55 | `PORT` is injected by Railway and the server prefers it over `API_PORT`. |
| 56 | |
| 57 | ### `web |
| 58 | |
| 59 | | Variable | Value | |
| 60 | | ------------------------------- | ------------------------------------------------- | |
| 61 | | `NEXT_PUBLIC_API_URL` | Public API URL, same as `BETTER_AUTH_URL |
| 62 | |
| 63 | `NEXT_PUBLIC_*` is inlined at **build** |
| 64 | a build argument. Set it as a Railway service variable *and* declare it under |
| 65 | *Settings → Build → Build arguments* (`NEXT_PUBLIC_API_URL |
| 66 | browser bundle keeps pointing at `http://localhost:3001`. Changing it later |
| 67 | requires a rebuild, not just a restart. |
| 68 | |
| 69 | ## 4. Domains and cookies |
| 70 | |
| 71 | The browser talks to the API directly, so sessions are only cross-origin-safe |
| 72 | when both services sit under one registrable domain: |
| 73 | |
| 74 | - `web` → `ghost.example.com` |
| 75 | - `api` → `api.ghost.example.com` |
| 76 | - `AUTH_COOKIE_DOMAIN` → `.example.com` |
| 77 | |
| 78 | `*.up.railway.app` is on the Public Suffix List, so a cookie cannot be shared |
| 79 | across two generated Railway subdomains. **Sign-in will not work on the default |
| 80 | domains** — add a custom domain to each service before testing auth. |
| 81 | |
| 82 | Clone URLs follow the API domain: |
| 83 | |
| 84 | ```sh |
| 85 | git clone https://api.ghost.example.com/<user |
| 86 | ``` |
| 87 | |
| 88 | ## 5. Migrations |
| 89 | |
| 90 | `railway.api.json` runs `node packages/db/dist/migrate.js` as the pre-deploy |
| 91 | command, which applies `packages/db/drizzle/*.sql` with the same connection |
| 92 | string as the app. A failed migration fails the deploy and the previous version |
| 93 | stays up. |
| 94 | |
| 95 | ## Building the images locally |
| 96 | |
| 97 | ```sh |
| 98 | docker build -f apps/api/Dockerfile |
| 99 | docker build -f apps/web/Dockerfile |
| 100 | --build-arg NEXT_PUBLIC_API_URL=https://api.ghost.example.com \ |
| 101 | -t ghost-web . |
| 102 | ``` |
| 103 | |
| 104 | The API image carries the `git` binary — every fetch and push shells out to it. |