| 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 | ### SSH |
| 92 | |
| 93 | The git SSH transport is off until `GIT_SSH_HOST_KEY` is set, so a deployment |
| 94 | without it serves git over HTTP alone and needs nothing else here. |
| 95 | |
| 96 | To turn it on: |
| 97 | |
| 98 | 1. Generate a host key once and keep it. Rotating it makes every client print |
| 99 | a changed-host-key warning. |
| 100 | |
| 101 | ```bash |
| 102 | ssh-keygen -t ed25519 -N '' -f ghost_host_key |
| 103 | base64 -w0 ghost_host_key # macOS: base64 -i ghost_host_key |
| 104 | ``` |
| 105 | |
| 106 | 2. Set `GIT_SSH_HOST_KEY` to that base64 blob on the `api` service, and |
| 107 | `GIT_SSH_PORT` to the port it should listen on. The default is `1031`; |
| 108 | **2222 is not available**, Railway uses it for its own container SSH. |
| 109 | 3. Railway's HTTP proxy cannot carry SSH, so add a **TCP proxy** on the |
| 110 | service pointing at `GIT_SSH_PORT`: |
| 111 | |
| 112 | ```bash |
| 113 | bunx railway tcp-proxy create --port 1031 --service api |
| 114 | bunx railway tcp-proxy list --service api --json |
| 115 | ``` |
| 116 | |
| 117 | Railway answers with a host and a public port of its own. That public port |
| 118 | is what people put in their clone URL — the container port stays 1031. |
| 119 | 4. Set `NEXT_PUBLIC_SSH_CLONE_HOST` on the `web` service to that |
| 120 | `host:port`, so the repository page offers the SSH clone URL. It is inlined |
| 121 | at build time, so set it before building. |
| 122 | |
| 123 | Tell people the host key's fingerprint — `ssh-keygen -lf ghost_host_key.pub` — |
| 124 | so their first connection has something to compare against. |
| 125 | |
| 126 | ### Storage limits |
| 127 | |
| 128 | Release assets are stored in the same bucket, under `release-assets/`. Two |
| 129 | variables on the `api` service bound them; sizes are bytes or binary units |
| 130 | (`1073741824`, `1gb`, ` |
| 131 | |
| 132 | | Variable | Effect | |
| 133 | | ------------------------- | ------------------------------------------------------------------- | |
| 134 | | `STORAGE_QUOTA_BYTES` | Most each account may store: a user, or an organization for its repositories. Empty (the default) means unlimited. | |
| 135 | | `RELEASE_ASSET_MAX_BYTES` | Largest single asset. `2gb |
| 136 | |
| 137 | A value the server cannot read stops it at boot rather than silently lifting |
| 138 | the limit. Lowering the quota deletes nothing: an account over it can still |
| 139 | read and delete its files, and cannot upload until it is back under. See |
| 140 | [0025](0025-storage-is-billed-to-the-owning-account.md) |
| 141 | |
| 142 | ### `web |
| 143 | |
| 144 | | Variable | Source | |
| 145 | | ----------------------- | ------------------------------- | |
| 146 | | `NEXT_PUBLIC_API_URL` | `https://<API_DOMAIN> |
| 147 | |
| 148 | `NEXT_PUBLIC_*` is inlined at **build** |
| 149 | to Dockerfile builds only through `ARG`, which `apps/web/Dockerfile` declares — |
| 150 | so the value is picked up automatically, but changing it needs a rebuild, not a |
| 151 | restart. |
| 152 | |
| 153 | ## 4. Domains and cookies |
| 154 | |
| 155 | The browser talks to the API directly, so sessions are only cross-origin-safe |
| 156 | when both services sit under one registrable domain: |
| 157 | |
| 158 | - `web` → `ghost.example.com` |
| 159 | - `api` → `api.ghost.example.com` |
| 160 | - `AUTH_COOKIE_DOMAIN` → `.example.com` |
| 161 | |
| 162 | `*.up.railway.app` is on the Public Suffix List, so a cookie cannot be shared |
| 163 | across two generated Railway subdomains. **Sign-in will not work on the default |
| 164 | domains.** Add both domains in the dashboard (configuration cannot register |
| 165 | them) and point their DNS at Railway before testing auth. The `ROOT_DOMAIN` |
| 166 | constant in `.railway/railway.ts` only feeds the URL variables. |
| 167 | |
| 168 | Clone URLs follow the API domain: |
| 169 | |
| 170 | ```sh |
| 171 | git clone https://api.ghost.example.com/<user |
| 172 | ``` |
| 173 | |
| 174 | ## 5. Migrations |
| 175 | |
| 176 | `.railway/railway.ts` runs `node packages/db/dist/migrate.js` as the API's |
| 177 | pre-deploy command, which applies `packages/db/drizzle/*.sql` with the same connection |
| 178 | string as the app. A failed migration fails the deploy and the previous version |
| 179 | stays up. |
| 180 | |
| 181 | ## Building the images locally |
| 182 | |
| 183 | ```sh |
| 184 | docker build -f apps/api/Dockerfile |
| 185 | docker build -f apps/web/Dockerfile |
| 186 | --build-arg NEXT_PUBLIC_API_URL=https://api.ghost.example.com \ |
| 187 | -t ghost-web . |
| 188 | ``` |
| 189 | |
| 190 | The API image carries the `git` binary — every fetch and push shells out to it. |
| 191 | |
| 192 | The web image starts with `HOSTNAME=0.0.0.0`. Next.js' standalone server binds |
| 193 | to whatever `$HOSTNAME` holds, and container runtimes set that to the container |
| 194 | id — a single interface, which answers a published Docker port but refuses the |
| 195 | Railway proxy. The `CMD` sets it at boot so a runtime-injected `HOSTNAME` cannot |
| 196 | win. |