Ghost

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.

Architecture

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.

Control plane · Vercel

Next.js

  • Prisma → Neon Postgres
  • Upstash Redis (event seq)
  • Workflow SDK (durable steps)
Runtime · Hetzner VM

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.
Golden image

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.

  1. 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.

  2. 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.

  3. 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.

Provisioning

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.

  1. 01

    Server action

    The createServer action kicks off the provisionServer workflow and writes a row with desiredState=ready.

  2. 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.

  3. 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.

  4. 04

    Agent enroll

    On boot, ghost-agent exchanges the bootstrap JWT for a persistent Ed25519 key registration via POST /api/agent/enroll.

  5. 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.

  6. 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.

Agent protocol

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/enroll exchanges 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, and X-Ghost-Sig. The signature is ed25519(method || path || ts || nonce || body) against a canonicalized body shared with the server via the protocol/ 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=25 hangs 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.
Infrastructure

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.