Part 2: Deploy a Service
In Part 1 you deployed a Server. Now
you’ll put code on it: a Hetzner.Service — an Effect program that
Alchemy bundles, copies over SSH, and runs as a systemd unit. No
Dockerfile, no unit files, no scp scripts.
Move the Server to a module
Section titled “Move the Server to a module”So far the resources live inside the Stack’s generator. To let other
files reference them, declare them at module scope instead — a
resource declared this way is an Effect you can import and yield*
anywhere. Create src/server.ts and move the key and Server from
Part 1 into it:
import * as Hetzner from "alchemy/Hetzner";
export const Key = Hetzner.SshKey("laptop", { publicKey: "ssh-ed25519 AAAA... you@laptop",});
export const Box = Hetzner.Server("box", { serverType: "cx22", image: "ubuntu-24.04", location: "nbg1", sshKeys: [Key],});Notice sshKeys: [Key] passes the declaration directly, without
yielding — resource-valued props accept the resource or an Effect
producing it. The logical ids are still laptop and box, so
Alchemy recognizes them as the same resources you deployed in
Part 1 — moving declarations between files doesn’t recreate
anything.
Update the Stack
Section titled “Update the Stack”Import the Server and yield it from the Stack:
import * as Alchemy from "alchemy";import * as Hetzner from "alchemy/Hetzner";import * as Effect from "effect/Effect";import { Box } from "./src/server.ts";
export default Alchemy.Stack( "MyApp", { providers: Hetzner.providers(), state: Alchemy.localState(), }, Effect.gen(function* () { const key = yield* Hetzner.SshKey("laptop", { publicKey: "ssh-ed25519 AAAA... you@laptop", });
const server = yield* Hetzner.Server("box", { serverType: "cx22", image: "ubuntu-24.04", location: "nbg1", sshKeys: [key], }); const server = yield* Box;
return { ipv4: server.ipv4, ipv6: server.ipv6, }; }),);The key is still part of the graph — it’s registered when the
Server’s sshKeys reference resolves.
Create the Service file
Section titled “Create the Service file”A Service in Alchemy is a class — it has both an infrastructure
definition and a runtime implementation expressed as an Effect.
Create src/api.ts with the smallest possible declaration:
import * as Hetzner from "alchemy/Hetzner";import * as Effect from "effect/Effect";import { Box } from "./server.ts";
export default class Api extends Hetzner.Service<Api>()( "Api", { server: Box, main: import.meta.url }, Effect.gen(function* () { return {}; }),) {}The <Api> type argument plus the empty () is a one-time bit of
ceremony — it lets TypeScript reason about Api as a typed handle.
server: Box says which machine this Service runs on, and
main: import.meta.url tells Alchemy this same file is the bundle
entrypoint: at deploy time it’s bundled with Rolldown and shipped to
the Server.
Serve HTTP with fetch
Section titled “Serve HTTP with fetch”Add a fetch field — Alchemy treats anything returned from the
Effect.gen block as the runtime API, and fetch specifically is
wired to an HTTP server listening on the Service’s port:
import * as Hetzner from "alchemy/Hetzner";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Box } from "./server.ts";
export default class Api extends Hetzner.Service<Api>()( "Api", { server: Box, main: import.meta.url }, Effect.gen(function* () { return {}; return { fetch: Effect.succeed(HttpServerResponse.text("Hello from Hetzner!")), }; }),) {}HttpServerResponse is the same effect/unstable/http API used on
every other Alchemy runtime — the handler you write here would run
unchanged on Cloudflare Workers or AWS Lambda.
Set the port
Section titled “Set the port”Give the Service an explicit port so we know where to reach it:
export default class Api extends Hetzner.Service<Api>()( "Api", { server: Box, main: import.meta.url }, { server: Box, main: import.meta.url, port: 3000 },The port is written to the process environment as PORT and used to
build the Service’s url attribute (http://<server-ipv4>:<port>).
Add a health route
Section titled “Add a health route”When a Service declares a port, the deploy doesn’t just start the
unit and hope — it polls http://127.0.0.1:<port>/health on the
Server until it answers, and fails the deploy (with the unit’s
journal in the output) if it never does. Add the route:
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";// ... return { fetch: Effect.succeed(HttpServerResponse.text("Hello from Hetzner!")), fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const url = new URL(request.url, "http://service"); if (url.pathname === "/health") { return HttpServerResponse.json({ ok: true }); } return HttpServerResponse.text("Hello from Hetzner!"); }), };Wire the Service into the Stack
Section titled “Wire the Service into the Stack”The Api class is just a typed identifier — yielding it inside the
Stack’s Effect is what registers the resource and starts the deploy:
import * as Alchemy from "alchemy";import * as Hetzner from "alchemy/Hetzner";import * as Effect from "effect/Effect";import Api from "./src/api.ts";import { Box } from "./src/server.ts";
export default Alchemy.Stack( "MyApp", { providers: Hetzner.providers(), state: Alchemy.localState(), }, Effect.gen(function* () { const server = yield* Box; const api = yield* Api;
return { ipv4: server.ipv4, ipv6: server.ipv6, url: api.url, }; }),);Yielding Api returns the resolved Service outputs — the systemd
unit name, the port, and the public url we surface from the Stack.
Deploy
Section titled “Deploy”bun alchemy deploynpm run alchemy deploypnpm alchemy deployyarn alchemy deployPlan: 1 to create + Api (Hetzner.Service) • box (Hetzner.Server) Proceed? ◉ Yes ○ No ✓ Api (Hetzner.Service) created { ipv4: "203.0.113.10", url: "http://203.0.113.10:3000", }
A lot just happened over one SSH connection:
src/api.tswas bundled with Rolldown into a single ESM file.- Alchemy connected to the Server using the deploy key it injected when the Server was created — no SSH configuration on your side.
- It unpacked the bundle to
/opt/<unit>/and wrote anEnvironmentFilewithPORTand the stack metadata. The Bun runtime is already there — Alchemy’s cloud-init bootstrap installed it when the Server was created. - It wrote a systemd unit (
Restart=always), enabled it, and waited for/healthto answer.
Try it out
Section titled “Try it out”curl http://203.0.113.10:3000# → Hello from Hetzner!The port is reachable because a fresh Hetzner server has no firewall — every port is open until you apply one. In Part 4 we’ll lock this down.
Ship a change
Section titled “Ship a change”Edit the greeting in src/api.ts and deploy again:
Plan: 1 to update ~ Api (Hetzner.Service) Proceed? ◉ Yes ○ No ✓ Api (Hetzner.Service) updated
Alchemy hashes the bundle: if your code didn’t change, the Service is a no-op; if it did, the new bundle is copied up and the unit restarted — a few seconds end to end.
You now have:
- An HTTP Service running on your Server as a systemd unit, with
Restart=alwayssupervision - A public URL serving requests, verified healthy at deploy time
- A code-hash-based update loop — edit, deploy, restarted
In Part 3, you’ll attach a Volume and persist data across deploys.