Unikraft Cloud backend

The Unikraft Cloud backend serves the same API as the Cloudflare one from a single image: a Rust server replaces the Worker and the Durable Objects, and PostgreSQL replaces their SQLite. Clients cannot tell the difference.

ClientRust serverActor alicePostgreSQLupgrade /v1/roots/aliceverify tokensocket for subjectconnect alice/notes/n1missing rows (Unix socket)role + syncupdatepulsify_append()ack, broadcast to subscribers
Inside one instance: the server is the only process reachable from the network.

One instance, two processes

image layout
instance (Unikraft Cloud, base-compat runtime)
├── pulsify-cloud-entrypoint     starts both processes, stops the instance if one exits
├── postgres                     Unix socket /var/run/postgresql only, data in /data/postgres
└── pulsify-cloud-server (Rust)  :8080, gateway + one actor per root
volume /data                     survives restarts and redeployments

PostgreSQL runs beside the server rather than on its own instance:

  • Nothing to secure between them. PostgreSQL listens on a Unix socket only: no port, no password, no TLS.
  • They sleep and wake together. With stateful scale-to-zero, one snapshot holds the server and the database, so a resumed instance answers its first frame without a database reconnection.
  • One thing to deploy. One image, one volume, one instance per deployment.

The trade-off is vertical scaling: one instance serves all its roots. Each root is still an isolated actor that processes its events in order, exactly like a Durable Object, so splitting roots across instances later changes no semantics.

1. Build the image

The server depends on the protocol crate of this repository. The script stages a self-contained build context (Kraftfile, Dockerfile, server and protocol sources) and runs unikraft build on it.

terminal
# From a clone of the Pulsify repository
unikraft login
bun run cloud:unikraft -- --output <my-org>/pulsify-cloud:latest

2. Create a volume and run

Keep the database on a volume mounted at /data. The idle policy suspends the instance between frames, even with sockets open; stateful=true resumes it from memory. Client heartbeats wake it briefly: pings go out when a silent socket is needed, plus one per silent socket every 2 s while someone uses the page. Tabs left open send nothing, so the instance sleeps. A short heartbeat.intervalMs or a long heartbeat.activeMs keeps it awake longer.

terminal
unikraft volume create --set metro=fra --set name=pulsify-data --set size=1Gi

unikraft run --metro fra \
  -m 1G \
  -p 443:8080/tls+http \
  --scale-to-zero policy=idle,cooldown-time=5000,stateful=true \
  -e PULSIFY_TOKEN_SECRET="$(openssl rand -base64 48)" \
  -e PULSIFY_API_KEY="$(openssl rand -base64 48)" \
  --volume pulsify-data:/data \
  --image <my-org>/pulsify-cloud:latest
# fqdn: young-thunder-fbafrsxj.fra.unikraft.app  →  PULSIFY_URL=https://…

Store both secrets: your backend needs PULSIFY_TOKEN_SECRET to sign tokens and PULSIFY_API_KEY to grant rights. Then follow the tutorial with the instance URL.

Run it locally

The same image runs in Docker:

terminal
bun run cloud:unikraft --docker pulsify-cloud

docker run --rm -p 8080:8080 \
  -e PULSIFY_TOKEN_SECRET="a local secret of at least thirty-two characters" \
  -e PULSIFY_API_KEY="a local api key of at least thirty-two characters" \
  -v pulsify-data:/data \
  pulsify-cloud

curl http://localhost:8080/v1/health   # {"ok":true}

Configuration

VariableDefaultMeaning
PULSIFY_TOKEN_SECRETrequiredHS256 secret shared with your backend, 32+ characters.
PULSIFY_API_KEYrequiredManagement key, 32+ characters.
PORT8080Port of the Rust server.
PGDATA/data/postgresDatabase directory; initialized on first boot.
DATABASE_URLthe local Unix socketPoint the server to another PostgreSQL instead.
PULSIFY_POOL_SIZE16PostgreSQL connections of the server.
PULSIFY_SHARED_BUFFERS64MBPostgreSQL shared_buffers; raise it with memory.

How it maps to Cloudflare

CloudflareUnikraft Cloud
Worker: routing, tokens, API keyRust server, same routes and errors
Durable Object per rootActor per root in the server, alive while a socket or request uses it
SQLite per rootOne PostgreSQL database; grants are keyed by root, rows by document ID
Subscription table for hibernationSubscriptions in memory: the process keeps its sockets
HibernationStateful scale-to-zero

The schema is the self-hosted PostgreSQL patch schema plus one grant table. It is exported for tooling and embedded in the server:

schema.ts
import { UNIKRAFT_CLOUD_POSTGRES_SCHEMA } from "pulsify-cloud-server/unikraft-cloud";

// pulsify_op_rows, pulsify_doc_clocks, pulsify_append(): the self-hosted patch schema
// pulsify_cloud_grants(root, subject, prefix, role): the grant tables of every root

Operations

  • Backups: the whole state is the volume. Snapshot it, or point DATABASE_URL at a managed PostgreSQL if you prefer its backups.
  • Usage: GET /v1/roots/{root}/usage with the API key returns documents, rows, bytes and open sockets of a root.
  • Stopping: the entrypoint stops the server, then PostgreSQL, on SIGTERM. If either process dies, the instance exits so the platform restarts both.
terminal
bun run test:cloud --backend unikraft                       # cargo build + local PostgreSQL
bun run test:cloud --backend unikraft --image pulsify-cloud  # the Docker image