How a click becomes a game server.
Ghost is a control plane on Vercel that orchestrates Docker game servers on your own Hetzner Cloud account. This page describes the architecture, how a new server is provisioned, how the agent on each VM communicates with the control plane, and how the golden image is built.
A control plane on Vercel, runtimes on Hetzner
The system has two halves. The control plane runs on Vercel and holds all coordination state. The runtime — the game container itself — runs on a Hetzner VM you own. The two communicate over a signed long-poll channel that the agent always initiates outbound, so VMs do not need to expose a management port.
Next.js
- Prisma → Neon Postgres
- Upstash Redis (event seq)
- Workflow SDK (durable steps)
ghost-agent
- Docker → game container
Browser ←SSE→ Vercel ←long-poll→ Hetzner VM
- Browser → Next.js on Vercel
- The dashboard, API routes, server actions, and Better Auth all run on Vercel. The control plane holds coordination state only; game state lives on the VMs.
- Workflow SDK (durable steps)
- Provisioning and teardown run as durable workflows via the Vercel Workflow SDK. Each step emits a structured activity event that the UI streams over SSE.
- Neon Postgres (Prisma)
- Server records, agent registrations, command queue, activity log, and ring-buffered console logs live in Neon Postgres via Prisma.
- Upstash Redis (event seq + nonce dedupe)
- Upstash Redis keeps a monotonic event sequence per server and a short-TTL nonce cache for replay protection on signed agent requests.
- Hetzner Cloud VMs
- Each game server is a Hetzner Cloud VM booted from your prebaked snapshot. Docker, the agent, and every game image are already on disk, so first boot does not need to install or download anything.
- ghost-agent (on each VM)
- The agent is a single Bun-compiled Linux binary that supervises one Docker Compose project per VM. It long-polls the API for commands and never accepts inbound connections.
A per-user Hetzner snapshot, baked from the dashboard
New servers are ready in roughly 60 seconds because almost nothing happens on first boot. Docker, the ghost-agent binary, the UFW baseline, and every supported game's container image are already on disk. A new VM only needs to read its bootstrap token and start a container.
That pre-baked disk is the golden image — a Hetzner snapshot scoped to your account. Snapshots are per-project, so each user builds their own. The build is triggered from the dashboard rather than the CLI.
- 01
Compile the agent
stepCompileAgent boots a Vercel Sandbox at the current commit SHA, runs bun install + bun run agent:build, and uploads dist/ghost-agent to Vercel Blob (private). It mints a short-lived JWT for GET /api/snapshot/agent-binary.
- 02
Spin up a builder VM
stepCreateBuilderVm POSTs to Hetzner with cloud-init userData that curls the binary, installs Docker and a UFW baseline, pre-pulls every game's image, and runs shutdown -h now.
- 03
Snapshot and swap
Once the VM is off, the workflow calls create_image, polls until the image is available, writes the new ID onto a UserSnapshot row scoped to the current deployment environment, then deletes both the builder VM and the previous snapshot.
The whole run takes ~10–15 minutes. You can watch it live in the panel itself, or with bun workflow:ui locally. Concurrent builds for the same user are blocked at the database level by a unique constraint on User.activeSnapshotBuildId. Adding a new game is a one-line change to the cloud-init in lib/workflows/build-snapshot-cloud-init.ts plus a rebuild.
From server action to healthy container, in six steps
Provisioning is a durable workflow, not a single request. Each step is idempotent and resumable. Hetzner can take 20–40 seconds to boot a VM, and a dropped connection should not be able to abandon a half-provisioned server.
- 01
Server action
The createServer action kicks off the provisionServer workflow and writes a row with desiredState=ready.
- 02
Bootstrap token
A short-lived bootstrap JWT is minted, scoped to a single server. Cloud-init writes it to /etc/ghost/bootstrap.json on first boot.
- 03
Hetzner create
Hetzner is asked to create a VM from your golden snapshot. The workflow waits for the VM to reach the running state.
- 04
Agent enroll
On boot, ghost-agent exchanges the bootstrap JWT for a persistent Ed25519 key registration via POST /api/agent/enroll.
- 05
Push compose
An UPDATE_CONFIG command is enqueued with the rendered docker-compose.yml. The agent picks it up within ~1s, writes it to disk, and runs docker compose up.
- 06
Healthy → ready
The workflow waits for the container to report healthy, then sets the row to ready. The dashboard streams activity events throughout the run, so the state change is reflected in the UI immediately.
Start, stop, and restart follow the same pattern: a row is added to the command queue, the agent picks it up on its next long-poll, executes the corresponding Docker command, and acks. Delete flips desiredState=deleted and starts the teardownServer workflow, which sends a final DELETE to the agent, destroys the Hetzner VM, and marks the row deleted.
Signed, outbound-only requests over long-poll
The ghost-agent is the only process on a game VM that communicates with Ghost. It only opens connections outward, so the VM's public ports are limited to the game's own.
- Enrollment
POST /api/agent/enrollexchanges the one-shot bootstrap JWT for a persistent Ed25519 public key registration. After that, the JWT is dead.- Signing
- Every subsequent request carries
X-Ghost-Agent,X-Ghost-Ts,X-Ghost-Nonce, andX-Ghost-Sig. The signature ised25519(method || path || ts || nonce || body)against a canonicalized body shared with the server via theprotocol/package. - Replay protection
- Timestamp skew tolerance is 60 seconds. Nonces are stored in Redis with a 5 minute TTL — long enough to cover the skew window, short enough to keep the set small.
- Long-poll for commands
GET /api/agent/commands?wait=25hangs for up to 25 seconds, polling Postgres every ~750ms, and returns either the next batch of commands or a 204. This is the path that turns a UI action into a Docker command on the VM.- Events, logs, heartbeat
- The agent batches activity events and console output, POSTs them to the API, and acks completed commands. Heartbeats are sent every ~30 seconds. The dashboard fans these back out to the browser over SSE, with a cursor on the monotonic sequence so reconnecting clients resume from where they left off.
Your Hetzner account, your token, your billing
Ghost does not hold the VMs. After signing in, you paste a Hetzner Cloud API token on /dashboard/account/backend, which is encrypted at rest in Postgres. Every Hetzner call — snapshot build, server create, server delete — runs against your project with your token, and is billed to you.
This means your snapshots and your servers continue to exist in your Hetzner account independently of Ghost. If you stop using Ghost, the resources remain under your control.