Hetzner
On Hetzner Cloud, the server runs in a Docker container on a Cloud server, with Caddy in front for HTTPS, and the store is an R2 bucket. Alchemy's Hetzner provider creates the server and its firewall, and its Docker provider runs the containers on it over SSH.
You need:
- The
src/server.ts,Dockerfileand.dockerignorefrom Deploying, andinfra/store.tsfrom The bucket. infra/docker.tsand thecaddyfolder from Your own server.- A Hetzner Cloud project and a Read & Write API token for it.
- An SSH key, loaded in your SSH agent, and the
dockercommand. It needs no local daemon: the images build on the server. - A domain name you can point at the server.
Install
bun add -d alchemy @alchemy.run/cloudflare @alchemy.run/hetzner @alchemy.run/dockerDescribe the stack
Create alchemy.run.ts in the project root:
import * as Cloudflare from "@alchemy.run/cloudflare"
import * as Docker from "@alchemy.run/docker"
import * as Hetzner from "@alchemy.run/hetzner"
import * as Alchemy from "alchemy"
import { Config, Effect, Layer, Redacted, Schedule } from "effect"
import { Server } from "./infra/docker.ts"
const installDocker = `#cloud-config
write_files:
- path: /etc/docker/daemon.json
content: |
{"log-driver": "local"}
runcmd:
- curl -fsSL https://get.docker.com | sh
`
const DockerReady = Alchemy.Action(
"DockerReady",
Effect.fn(function* (input: {
host: string | undefined
privateKey: Redacted.Redacted<string> | undefined
}) {
if (input.host === undefined || input.privateKey === undefined) {
return yield* Effect.die("The server has no public IPv4 address or deploy key")
}
const ssh = yield* Hetzner.openSshClient({
host: input.host,
privateKey: Redacted.value(input.privateKey),
})
yield* ssh
.exec("cloud-init status --wait > /dev/null; docker info > /dev/null")
.pipe(
Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 60 }),
Effect.scoped,
Effect.ensuring(ssh.close),
)
return { docker: `host=ssh://root@${input.host}` }
}),
)
export default Alchemy.Stack(
"my-app",
{
providers: Layer.mergeAll(Cloudflare.providers(), Hetzner.providers(), Docker.providers()),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const key = yield* Hetzner.SshKey("Key", {
publicKey: yield* Config.String("SSH_PUBLIC_KEY"),
})
const firewall = yield* Hetzner.Firewall("Firewall", {
rules: ["22", "80", "443"].map((port) => ({
direction: "in" as const,
protocol: "tcp" as const,
port,
sourceIps: ["0.0.0.0/0", "::/0"],
})),
})
const machine = yield* Hetzner.Server("Machine", {
serverType: "cx23",
image: "ubuntu-24.04",
location: "nbg1",
sshKeys: [key],
firewalls: [firewall],
userData: installDocker,
})
const ready = yield* DockerReady({ host: machine.ipv4, privateKey: machine.privateKey })
const engine = yield* Docker.Context("Engine", { docker: ready.docker })
const server = yield* Server(engine)
return { ...server, ip: machine.ipv4 }
}),
)installDockeris cloud-init: it installs Docker the first time the server boots.DockerReadywaits over SSH until cloud-init has finished and Docker answers. The server reportsrunninglong before that.Machineis acx23server, with 2 shared vCPUs and 4 GB, in Nuremberg. Pick another type and location to suit; cost-optimized types likecx23sometimes sell out, andcpx22is the regular choice.Firewalllets in SSH, HTTP and HTTPS.Engineis a Docker context that reaches the server's Docker over SSH, andServerfrominfra/docker.tsruns the server and Caddy there.
Deploy
Set SSH_PUBLIC_KEY, DOMAIN and your AUTH_* settings, in your shell or .env. The server's address isn't known until it exists, so let SSH accept its host key the first time, in ~/.ssh/config:
Host *
StrictHostKeyChecking accept-newThen deploy:
bun alchemy deploy --stage prodThe first time, Alchemy asks for your Hetzner token and your Cloudflare account. In CI it reads HCLOUD_TOKEN. It creates the server, waits for Docker, builds both images on the server, starts the containers, and prints the server's ip.
Point an A record for your domain at that address. Caddy gets a certificate as soon as the name resolves. Check the server:
curl https://<domain>/readyReleases
Run bun alchemy deploy --stage prod again. Alchemy builds the new image on the server, then stops the old container, giving it 30 seconds after SIGTERM, and starts the new one. Caddy holds requests for up to 30 seconds while the server restarts.
Changing the server's type, location, image or cloud-init makes Alchemy create a new server before it deletes the old one, so two servers would run at once. Resize from the Hetzner console instead, or move in two deploys: remove the server from the stack, deploy, then add the new one.
bun alchemy destroy --stage prod deletes the containers and the server. It leaves the bucket while it holds objects.