| 1 | --- |
| 2 | title: "Part 2: Deploy a Service" |
| 3 | description: Bundle an Effect HTTP server, deploy it to Railway as a Service, and serve requests on up.railway.app. |
| 4 | sidebar: |
| 5 | order: 2 |
| 6 | --- |
| 7 | |
| 8 | import Terminal from "../../../../components/Terminal.astro"; |
| 9 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 10 | |
| 11 | In [Part 1](/railway/tutorial/part-1) you deployed a Project. Now you |
| 12 | will deploy a `Railway.Service`: an Effect program running in a |
| 13 | Railway container. Alchemy bundles it, generates a Dockerfile, and |
| 14 | uploads the context. Railway builds the image. |
| 15 | |
| 16 | :::note[Image Services] |
| 17 | Pass `image: "hashicorp/http-echo"` to skip the build and pull a |
| 18 | public tag instead — see [Services](/railway/compute/services). |
| 19 | ::: |
| 20 | |
| 21 | ## Move the Project to a module |
| 22 | |
| 23 | So far the Project lives inside the Stack's generator. To let other |
| 24 | files reference it, declare it at **module scope** instead — a |
| 25 | resource declared this way is an Effect you can import and `yield*` |
| 26 | anywhere. Create `src/project.ts`: |
| 27 | |
| 28 | ```typescript |
| 29 | // src/project.ts |
| 30 | import * as Railway from "alchemy/Railway"; |
| 31 | |
| 32 | export const Site = Railway.Project("Site"); |
| 33 | ``` |
| 34 | |
| 35 | The logical id is still `Site`, so Alchemy recognizes it as the same |
| 36 | Project you deployed in Part 1 — moving declarations between files |
| 37 | doesn't recreate anything. |
| 38 | |
| 39 | ## Update the Stack |
| 40 | |
| 41 | Import the Project and yield it from the Stack: |
| 42 | |
| 43 | ```diff lang="typescript" |
| 44 | // alchemy.run.ts |
| 45 | import * as Alchemy from "alchemy"; |
| 46 | import * as Railway from "alchemy/Railway"; |
| 47 | import * as Effect from "effect/Effect"; |
| 48 | +import { Site } from "./src/project.ts"; |
| 49 | |
| 50 | export default Alchemy.Stack( |
| 51 | "MyApp", |
| 52 | { |
| 53 | providers: Railway.providers(), |
| 54 | state: Alchemy.localState(), |
| 55 | }, |
| 56 | Effect.gen(function* () { |
| 57 | - const site = yield* Railway.Project("Site"); |
| 58 | + const site = yield* Site; |
| 59 | |
| 60 | return { |
| 61 | name: site.name, |
| 62 | projectId: site.projectId, |
| 63 | url: site.url, |
| 64 | }; |
| 65 | }), |
| 66 | ); |
| 67 | ``` |
| 68 | |
| 69 | ## Create the Service file |
| 70 | |
| 71 | A Service in Alchemy is a class — it has both an infrastructure |
| 72 | definition and a runtime implementation expressed as an Effect. |
| 73 | Create `src/api.ts` with the smallest possible declaration: |
| 74 | |
| 75 | ```typescript |
| 76 | // src/api.ts |
| 77 | import * as Railway from "alchemy/Railway"; |
| 78 | import * as Effect from "effect/Effect"; |
| 79 | import { Site } from "./project.ts"; |
| 80 | |
| 81 | export default class Api extends Railway.Service<Api>()( |
| 82 | "Api", |
| 83 | { |
| 84 | project: Site, |
| 85 | main: import.meta.url, |
| 86 | }, |
| 87 | Effect.gen(function* () { |
| 88 | return {}; |
| 89 | }), |
| 90 | ) {} |
| 91 | ``` |
| 92 | |
| 93 | The `<Api>` type argument plus the empty `()` is a one-time bit of |
| 94 | ceremony — it lets TypeScript reason about `Api` as a typed handle. |
| 95 | `project: Site` says which Project this Service runs in, and |
| 96 | `main: import.meta.url` tells Alchemy this same file is the bundle |
| 97 | entrypoint. |
| 98 | |
| 99 | ## Serve HTTP with `fetch` |
| 100 | |
| 101 | Add a `fetch` field — Alchemy treats anything returned from the |
| 102 | `Effect.gen` block as the runtime API, and `fetch` specifically is |
| 103 | wired to an HTTP server listening on the Service's port: |
| 104 | |
| 105 | ```diff lang="typescript" |
| 106 | // src/api.ts |
| 107 | import * as Railway from "alchemy/Railway"; |
| 108 | import * as Effect from "effect/Effect"; |
| 109 | +import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 110 | import { Site } from "./project.ts"; |
| 111 | |
| 112 | export default class Api extends Railway.Service<Api>()( |
| 113 | "Api", |
| 114 | { |
| 115 | project: Site, |
| 116 | main: import.meta.url, |
| 117 | }, |
| 118 | Effect.gen(function* () { |
| 119 | - return {}; |
| 120 | + return { |
| 121 | + fetch: Effect.succeed(HttpServerResponse.text("Hello from Railway!")), |
| 122 | + }; |
| 123 | }), |
| 124 | ) {} |
| 125 | ``` |
| 126 | |
| 127 | `HttpServerResponse` is the same `effect/http` API used on |
| 128 | every other Alchemy runtime — the handler you write here would run |
| 129 | unchanged on Cloudflare Workers or AWS Lambda. |
| 130 | |
| 131 | :::note |
| 132 | Notice the "Effect returning an Effect" pattern. The outer generator |
| 133 | is the **Construction phase** — it runs at deploy time and at process |
| 134 | startup. The inner `fetch` generator is the **Runtime phase** — it |
| 135 | runs per request. |
| 136 | ::: |
| 137 | |
| 138 | ## Pin a region |
| 139 | |
| 140 | Railway Services live in a [region](/railway/compute/regions). Omit |
| 141 | it and Railway picks a default. Set it explicitly so the Volume you |
| 142 | add in Part 3 can match: |
| 143 | |
| 144 | ```diff lang="typescript" |
| 145 | export default class Api extends Railway.Service<Api>()( |
| 146 | "Api", |
| 147 | - { project: Site, main: import.meta.url }, |
| 148 | + { project: Site, main: import.meta.url, region: "us-west2" }, |
| 149 | ``` |
| 150 | |
| 151 | Updating `region` later is in place. |
| 152 | |
| 153 | ## Set the port |
| 154 | |
| 155 | Give the Service an explicit port so Railway's edge knows where to |
| 156 | forward: |
| 157 | |
| 158 | ```diff lang="typescript" |
| 159 | export default class Api extends Railway.Service<Api>()( |
| 160 | "Api", |
| 161 | - { project: Site, main: import.meta.url, region: "us-west2" }, |
| 162 | + { project: Site, main: import.meta.url, region: "us-west2", port: 3000 }, |
| 163 | ``` |
| 164 | |
| 165 | The port is written to the process environment as `PORT` and used as |
| 166 | the generated domain's `targetPort`. Alchemy publishes HTTPS on |
| 167 | Railway's edge in front of it. |
| 168 | |
| 169 | ## Add a health route |
| 170 | |
| 171 | Add `/health` so you can probe the process: |
| 172 | |
| 173 | ```diff lang="typescript" |
| 174 | // src/api.ts |
| 175 | +import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 176 | // ... |
| 177 | return { |
| 178 | - fetch: Effect.succeed(HttpServerResponse.text("Hello from Railway!")), |
| 179 | + fetch: Effect.gen(function* () { |
| 180 | + const request = yield* HttpServerRequest; |
| 181 | + const url = new URL(request.url, "http://service"); |
| 182 | + if (url.pathname === "/health") { |
| 183 | + return HttpServerResponse.json({ ok: true }); |
| 184 | + } |
| 185 | + return HttpServerResponse.text("Hello from Railway!"); |
| 186 | + }), |
| 187 | }; |
| 188 | ``` |
| 189 | |
| 190 | ## Wire the Service into the Stack |
| 191 | |
| 192 | The `Api` class is just a typed identifier — yielding it inside the |
| 193 | Stack's Effect is what registers the resource and starts the deploy: |
| 194 | |
| 195 | ```diff lang="typescript" |
| 196 | // alchemy.run.ts |
| 197 | import * as Alchemy from "alchemy"; |
| 198 | import * as Railway from "alchemy/Railway"; |
| 199 | import * as Effect from "effect/Effect"; |
| 200 | +import Api from "./src/api.ts"; |
| 201 | import { Site } from "./src/project.ts"; |
| 202 | |
| 203 | export default Alchemy.Stack( |
| 204 | "MyApp", |
| 205 | { |
| 206 | providers: Railway.providers(), |
| 207 | state: Alchemy.localState(), |
| 208 | }, |
| 209 | Effect.gen(function* () { |
| 210 | const site = yield* Site; |
| 211 | + const api = yield* Api; |
| 212 | |
| 213 | return { |
| 214 | name: site.name, |
| 215 | projectId: site.projectId, |
| 216 | - url: site.url, |
| 217 | + url: api.url, |
| 218 | }; |
| 219 | }), |
| 220 | ); |
| 221 | ``` |
| 222 | |
| 223 | Yielding `Api` returns the resolved Service outputs — the service |
| 224 | id, region, and the public `url`. |
| 225 | |
| 226 | ## Deploy |
| 227 | |
| 228 | <Tabs syncKey="pkgManager"> |
| 229 | <TabItem label="bun" icon="bun"> |
| 230 | <Code code="bun alchemy deploy" lang="sh" /> |
| 231 | </TabItem> |
| 232 | <TabItem label="npm" icon="npm"> |
| 233 | <Code code="npm run alchemy deploy" lang="sh" /> |
| 234 | </TabItem> |
| 235 | <TabItem label="pnpm" icon="pnpm"> |
| 236 | <Code code="pnpm alchemy deploy" lang="sh" /> |
| 237 | </TabItem> |
| 238 | <TabItem label="yarn" icon="seti:yarn"> |
| 239 | <Code code="yarn alchemy deploy" lang="sh" /> |
| 240 | </TabItem> |
| 241 | </Tabs> |
| 242 | |
| 243 | <Terminal content={`[u]Plan[/u]: [g]1 to create[/g] |
| 244 | |
| 245 | [g]+[/g] [b]Api[/b] [d](Railway.Service)[/d] |
| 246 | [d]•[/d] [b]Site[/b] [d](Railway.Project)[/d] |
| 247 | |
| 248 | Proceed? |
| 249 | ◉ Yes ○ No |
| 250 | [g]✓[/g] [b]Api[/b] [d](Railway.Service)[/d] [g]created[/g] |
| 251 | { |
| 252 | [s2]name: "myapp-site-dev-a1b2c3d4", |
| 253 | [s2]projectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", |
| 254 | [s2]url: "https://myapp-api-dev-a1b2c3d4.up.railway.app", |
| 255 | }`} /> |
| 256 | |
| 257 | A lot just happened: |
| 258 | |
| 259 | 1. `src/api.ts` was bundled with Rolldown into a single ESM file. |
| 260 | 2. Alchemy generated a Dockerfile (`FROM oven/bun:1`) and uploaded |
| 261 | the context. Railway built the image. |
| 262 | 3. It created the Service, attached a `*.up.railway.app` domain, and |
| 263 | pointed Railway's edge at port 3000. |
| 264 | |
| 265 | ## Try it out |
| 266 | |
| 267 | ```sh |
| 268 | curl https://myapp-api-dev-a1b2c3d4.up.railway.app |
| 269 | # → Hello from Railway! |
| 270 | ``` |
| 271 | |
| 272 | ## Ship a change |
| 273 | |
| 274 | Edit the greeting in `src/api.ts` and deploy again: |
| 275 | |
| 276 | <Terminal content={`[u]Plan[/u]: [y]1 to update[/y] |
| 277 | |
| 278 | [y]~[/y] [b]Api[/b] [d](Railway.Service)[/d] |
| 279 | |
| 280 | Proceed? |
| 281 | ◉ Yes ○ No |
| 282 | [g]✓[/g] [b]Api[/b] [d](Railway.Service)[/d] [y]updated[/y]`} /> |
| 283 | |
| 284 | Alchemy hashes the bundle: if your code didn't change, the Service |
| 285 | is a no-op; if it did, a new image is built, pushed, and the Service |
| 286 | updated in place. |
| 287 | |
| 288 | ## Recap |
| 289 | |
| 290 | You now have: |
| 291 | |
| 292 | - An HTTP Service running as a Railway container in `us-west2` |
| 293 | - A public `https://{name}.up.railway.app` URL |
| 294 | - A code-hash-based update loop — edit, deploy, new image |
| 295 | |
| 296 | In [Part 3](/railway/tutorial/part-3), you'll attach a Volume and |
| 297 | persist data across deploys. |