Hexclave (formerly Stack Auth) is an open-source user-infrastructure platform — auth, teams, RBAC, API keys, and analytics — shipped as a single stackauth/server container backed by Postgres and ClickHouse. It runs via Docker, so start from a snapshot that already has Docker installed by following How to Run Docker in a Sandbox. This guide boots a VM from that snapshot, brings the stack up, and routes a public domain to it.
The one structural wrinkle: the container exposes the API on port 8102 and the dashboard on 8101, and a domain maps a single port — so a small nginx reverse proxy splits one public domain across both by path.
Install the SDK
pnpm add freestyle@latestbun add freestyle@latestnpm install freestyle@latestyarn add freestyle@latest Set your API key before calling the API:
export FREESTYLE_API_KEY="your-api-key"
Boot a Bigger VM from the Docker Snapshot
Create a VM from the Docker snapshot and resize it first. Hexclave bundles ClickHouse, which is memory-hungry — on the default VM (4 vCPU / 8 GiB / 32 GB) the box runs out of memory mid-startup and the stack collapses. Grow it before doing any work; cpu and memory must be powers of two, and memory and storage are in GB.
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
// dockerSnapshotId comes from the Docker guide linked above.
const { vm, vmId } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "hexclave-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
// ClickHouse + the stackauth server want real headroom.
await vm.resize({ cpu: 4, memory: 16 * 1024, storage: 60 * 1024 }); // MiB
// resize reboots the VM; Docker is enabled in the snapshot, so wait for dockerd.
for (let i = 0; i < 30; i++) {
const r = await vm.exec("docker info >/dev/null 2>&1 && echo READY || echo WAIT");
if (r.stdout?.includes("READY")) break;
await new Promise((res) => setTimeout(res, 2000));
}
// Pick the domain now — the env file bakes it in, and you'll route it at the end.
// Any unused style.dev subdomain works, or one of your own you have verified.
const domain = "hexclave.style.dev";
Write the Compose Files
Hexclave is configured entirely through environment variables. Generate its secrets locally, then write three files: hexclave.env (config + the keys that seed the first admin user), docker-compose.yml (the four services), and nginx.conf (the path split). NEXT_PUBLIC_STACK_API_URL is baked into the dashboard at startup, so it must already be your public domain.
import crypto from "node:crypto";
const hex = () => crypto.randomBytes(32).toString("hex");
const serverSecret = crypto.randomBytes(32).toString("base64url");
const adminPassword = crypto.randomBytes(9).toString("base64url"); // log in with this
await vm.exec("mkdir -p /root/hexclave");
await vm.fs.writeTextFile(
"/root/hexclave/hexclave.env",
`NEXT_PUBLIC_STACK_API_URL=https://${domain}
NEXT_PUBLIC_STACK_DASHBOARD_URL=https://${domain}
STACK_DATABASE_CONNECTION_STRING=postgresql://postgres:password@postgres:5432/stackframe
STACK_SERVER_SECRET=${serverSecret}
CRON_SECRET=${hex()}
STACK_CLICKHOUSE_URL=http://clickhouse:8123
STACK_CLICKHOUSE_DATABASE=analytics
STACK_CLICKHOUSE_ADMIN_USER=stackframe
STACK_CLICKHOUSE_ADMIN_PASSWORD=password
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=${hex()}
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${hex()}
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${hex()}
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${hex()}
STACK_SEED_INTERNAL_PROJECT_USER_EMAIL=admin@example.com
STACK_SEED_INTERNAL_PROJECT_USER_PASSWORD=${adminPassword}
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=true
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=false
STACK_EMAILABLE_API_KEY=disable_email_validation
STACK_RUN_MIGRATIONS=true
STACK_RUN_SEED_SCRIPT=true
`,
);
Now the Compose file. The critical detail is restart: always on every service: on first boot the stackauth server runs its Postgres migrations and then immediately tries ClickHouse, which is still starting — it hits ECONNREFUSED on :8123 and exits. The restart policy lets it come straight back and succeed once ClickHouse is accepting connections, instead of leaving the stack dead.
await vm.fs.writeTextFile(
"/root/hexclave/docker-compose.yml",
`services:
postgres:
image: postgres:latest
restart: always
environment: { POSTGRES_USER: postgres, POSTGRES_PASSWORD: password, POSTGRES_DB: stackframe }
networks: [hexclave]
clickhouse:
image: clickhouse/clickhouse-server:25.10
restart: always
environment: { CLICKHOUSE_DB: analytics, CLICKHOUSE_USER: stackframe, CLICKHOUSE_PASSWORD: password, CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" }
mem_limit: 4g
networks: [hexclave]
hexclave:
image: stackauth/server:latest
restart: always
env_file: hexclave.env
depends_on: [postgres, clickhouse]
networks: [hexclave]
nginx:
image: nginx:alpine
restart: always
ports: ["80:80"]
depends_on: [hexclave]
volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
networks: [hexclave]
networks:
hexclave:
`,
);
await vm.fs.writeTextFile(
"/root/hexclave/nginx.conf",
`server {
listen 80;
location /api/ { proxy_pass http://hexclave:8102; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
location / { proxy_pass http://hexclave:8101; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
}
`,
);
Start the Stack
Bring it up with docker compose up -d. The first boot is slow — pulling the ~950 MB server image, then running Postgres and ClickHouse migrations and seeding the admin project before the dashboard answers — so poll the nginx port until it returns 200 (expect a couple of minutes; 502 just means the server is still starting behind nginx).
await vm.exec({
command: "cd /root/hexclave && docker compose up -d",
timeoutMs: 300_000, // first run pulls ~950 MB + ClickHouse
});
let code = "";
for (let i = 0; i < 40 && code !== "200"; i++) {
await new Promise((res) => setTimeout(res, 5000));
code = (
await vm.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost/ || echo 000")
).stdout.trim();
}
console.log(code); // 200 once migrations + seed finish
// The internal endpoint confirms the API side (through nginx) is live too.
const urls = await vm.exec("curl -s http://localhost/api/v1/internal/backend-urls");
console.log(urls.stdout); // {"urls":["https://<domain>"]}
Snapshot for Fast Boots
The stackauth/server (~950 MB) and ClickHouse images make the first pull the slow part. Snapshot the resized VM once they’re cached so future instances skip the download:
const { snapshotId } = await vm.snapshot();
Boot new instances from that snapshotId (keep them resized for ClickHouse), write hexclave.env with the new domain, and docker compose up -d — restart: always still rides out the ClickHouse startup race, but with the images already local the dashboard comes up much faster.
Stream the Logs
vm.exec() buffers a command and only returns once it finishes, so it can’t show the stack’s output as it happens. To watch the logs live — useful while the migrations and seed run — open a PTY on the VM (a real terminal streamed over a WebSocket, server-side only, Node 22+) and follow the server with docker compose logs -f. onData delivers the bytes as they arrive:
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow the stackauth server; watch for "Ready" once it has migrated and seeded.
session.write("cd /root/hexclave && docker compose logs -f hexclave\n");
// session.detach() drops your handle — the containers keep running.
Open It on a Domain
Route the domain you picked earlier to nginx on port 80 — that one rule covers both surfaces, since nginx routes /api/ to the API and everything else to the dashboard. Right after the rule is created, the Freestyle proxy may briefly serve a “Reloading” warmup page (also a 200), so poll until Hexclave itself answers.
await freestyle.tls.rules.create({
action: "allow",
domain,
source: { public: true },
destination: { vmId, port: 80 },
});
// Poll past the proxy warmup page until the real dashboard responds.
let ok = false;
for (let i = 0; i < 15; i++) {
const res = await fetch(`https://${domain}/api/v1/internal/backend-urls`);
if (res.ok && (await res.text()).includes(domain)) { ok = true; break; }
await new Promise((r) => setTimeout(r, 3000));
}
console.log(ok); // true
console.log(`Hexclave: https://${domain} (sign in: admin@example.com / ${adminPassword})`);
Open https://${domain} and sign in with the seeded admin — admin@example.com and the adminPassword you generated. From there you can create projects and wire your app to this instance by pointing NEXT_PUBLIC_STACK_API_URL (and the project keys) at the domain. Two notes for a real deployment: keep STACK_SERVER_SECRET stable across restarts (changing it invalidates every session), and Hexclave expects a few internal cron endpoints to be hit on a schedule (the email queue and ClickHouse sync) — fine to skip for a sandbox, but wire them up with a timer if you depend on email or analytics.