Services
A Service is your code on a
Server: an Effect program that Alchemy
bundles with Rolldown, copies over SSH, and runs under systemd with
Restart=always. You never write a unit file, a Dockerfile, or a
deploy script — and several Services can share one Server.
Declare a Service
Section titled “Declare a Service”A Service is a class with an infrastructure definition and a runtime implementation:
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, port: 3000 }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}server— the machine to run on (aHetzner.Server, yielded or module-scope). Changing it replaces the Service.main: import.meta.url— this file is the bundle entrypoint.port— written to thePORTenv var and used to build theurlattribute (http://<server-ipv4>:<port>). Default3000.fetch— returning it from the init Effect boots an HTTP server on that port; omit it for a background service.
The resolved resource exposes url, port, unitName, serverId,
and the bundle’s code.hash.
What a deploy does
Section titled “What a deploy does”One SSH session, using the Server’s Alchemy-managed deploy key:
-
Bundle
mainwith Rolldown into a single ESM file (a generated bootstrap wires up the Bun HTTP server, logging, and config from the environment). -
Attach and mount any bound Volumes.
-
Unpack the bundle to
/opt/<unitName>/, write the env file, and install the systemd unit:[Service]WorkingDirectory=/opt/<unitName>EnvironmentFile=-/opt/<unitName>/envExecStart=/root/.bun/bin/bun --no-install /opt/<unitName>/index.mjsRestart=alwaysRestartSec=5 -
systemctl enable --now, restart, and wait for the unit to be active — and, when aportis declared, forhttp://127.0.0.1:<port>/healthto answer.
On later deploys Alchemy compares the bundle’s content hash against what’s deployed: unchanged code is a no-op; changed code is copied up and the unit restarted, a few seconds end to end.
Reachability
Section titled “Reachability”The url is the Server’s public IP and the Service’s port —
there’s no proxy in between. A fresh Hetzner server has no firewall
(every port open); once you apply a
Firewall, inbound is default-deny
and you choose who reaches the port. For a stable front door with
health checks and TLS, put a
Load Balancer in front and
allow the port only from the private network.
Environment & config
Section titled “Environment & config”Pass static values with env; read them at runtime through Effect’s
Config (the runtime’s ConfigProvider is backed by the process
environment):
import * as Config from "effect/Config";
export default class Api extends Hetzner.Service<Api>()( "Api", { server: Box, main: import.meta.url, port: 3000, env: { GREETING: "hello" }, }, Effect.gen(function* () { const greeting = yield* Config.string("GREETING"); // ... }),) {}Alchemy also injects PORT (when port is set) and the stack
metadata (ALCHEMY_STACK_NAME, ALCHEMY_STAGE). Values from
bindings are merged first, so your env wins on conflict. See
Secrets & Config for handling secret
values.
Background services
Section titled “Background services”Omit port/fetch and use ServerHost.run to register a
long-running loop — a worker, a queue consumer, a cron-ish daemon:
import * as Hetzner from "alchemy/Hetzner";import { ServerHost } from "alchemy/Server";import * as Effect from "effect/Effect";
export default class Worker extends Hetzner.Service<Worker>()( "Worker", { server: Box, main: import.meta.url }, Effect.gen(function* () { const host = yield* ServerHost;
yield* host.run( Effect.gen(function* () { // do work forever return yield* Effect.never; }).pipe(Effect.orDie), ); }),) {}systemd still supervises the process — if your loop exits or crashes, the unit restarts after 5 seconds.
Multiple Services, one Server
Section titled “Multiple Services, one Server”Each Service is its own unit with its own bundle, env file, and
lifecycle. Point several at the same server and share resources
between them — two Services mounting the same Volume at the same
path is one attach and one mount:
// Api serves what Worker writes — same Box, same Volumeclass Api extends Hetzner.Service<Api>()( "Api", { server: Box, main: import.meta.url, port: 3000 }, /* mounts Data at /data, serves files */) {}
class Worker extends Hetzner.Service<Worker>()( "Worker", { server: Box, main: import.meta.url }, /* mounts Data at /data, writes files */) {}Service logs go to the systemd journal. alchemy logs/alchemy tail don’t support Hetzner Services yet — read them over SSH:
ssh root@<server-ip> journalctl -u <unitName> -fThe unit name is the Service’s unitName output (surface it as a
stack output to keep it handy). Failed deploys already print the
last 80 journal lines.
Where next
Section titled “Where next”- Tutorial part 2 — build up a Service step by step.
- Volumes — persistent storage via the
MountVolumebinding. - Networking — firewalls and load balancers in front of your Service.
Servicereference — every prop and attribute.