Skip to content

Part 4: Run It on Hetzner

Nothing in the room mentions Docker. The control plane and the pool are Layers, so you swap them for Hetzner’s, move the Api onto a Hetzner Server, and the same room runs on Hetzner Cloud. This part costs money while it runs; the last step removes it.

Finish Hetzner setup, and install @alchemy.run/hetzner.

Terminal window
pnpm alchemy destroy

This removes the Docker control plane, the pool and the API. Do it before you swap the providers below: a Stack without Docker.providers() can’t delete Docker resources.

src/cluster.ts
import * as RivetDocker from "@alchemy.run/rivet/Docker";
import * as RivetHetzner from "@alchemy.run/rivet/Hetzner";
import * as Layer from "effect/Layer";
export const Cluster = RivetDocker.SwarmControlPlane("Rivet");
export const Cluster = RivetHetzner.ServerControlPlane("Rivet");
export const Default = RivetDocker.SwarmPool("Default").pipe(Layer.provide(Cluster));
export const Default = RivetHetzner.ServerPool("Default").pipe(Layer.provide(Cluster));

The control plane runs on a Hetzner Server, behind an HTTPS edge at an sslip.io address. The pool gets a Server of its own. src/room.ts doesn’t change.

src/api.ts
import * as Docker from "@alchemy.run/docker";
import * as Rivet from "@alchemy.run/rivet";
import * as Hetzner from "@alchemy.run/hetzner";
import * as Effect from "effect/Effect";
import * as HttpServerRequest from "effect/http/HttpServerRequest";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
import * as Schema from "effect/Schema";
import { Cluster } from "./cluster.ts";
import Room from "./room.ts";
const Message = Schema.Struct({ author: Schema.String, text: Schema.String });
export const ApiServer = Hetzner.Server("ApiServer", {
serverType: "cx23",
image: "ubuntu-24.04",
location: "nbg1",
});
src/api.ts
export default class Api extends Docker.Service<Api>()(
export default class Api extends Hetzner.Service<Api>()(
"Api",
Effect.gen(function* () {
const cluster = yield* Rivet.Cluster;
return {
main: import.meta.url,
port: 3000,
ports: [{ external: 3000, internal: 3000 }],
networks: cluster.network?.kind === "docker" ? [{ name: cluster.network.name }] : [],
};
}).pipe(Effect.provide(Cluster)),
{ server: ApiServer, main: import.meta.url },
Effect.gen(function* () {
const rooms = yield* Room;

The control plane has a public HTTPS address on Hetzner, so the API no longer joins a network. The fetch handler stays the same.

alchemy.run.ts
import * as Docker from "@alchemy.run/docker";
import * as Hetzner from "@alchemy.run/hetzner";
import * as Rivet from "@alchemy.run/rivet";
providers: Layer.mergeAll(Docker.providers(), Rivet.providers()),
providers: Layer.mergeAll(Hetzner.providers(), Rivet.providers()),
return { url: "http://localhost:3000", service: api.name };
return { url: api.url };
Terminal window
pnpm alchemy deploy

The rooms start empty: their data was on the Docker control plane. The new control plane takes a few minutes to start.

Terminal window
curl -X POST <url>/lobby/join
# {"visitors":1}

Use the url the deploy printed. You have the same rooms running on Hetzner, with no change to the room itself.

The Servers and the Load Balancer cost money while they run:

Terminal window
pnpm alchemy destroy