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 *.style.dev domain maps a single port — so a small nginx reverse proxy splits one public domain across both by path.
Install the SDK
pnpm add freestylebun add freestylenpm install freestyleyarn add freestyle 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 GB / 20 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";
// dockerSnapshotId comes from the Docker guide linked above.
const { vm, vmId } = await freestyle.vms.create({
slug: "hexclave-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
// ClickHouse + the stackauth server want real headroom.
await vm.resize({ cpu: 4, memory: 16, storage: 60 }); // memory and storage in GB
// 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 map it at the end.
const domain = `hexclave-${crypto.randomUUID().slice(0, 8)}.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: 600_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
Map the domain you picked earlier to nginx on port 80 — that one mapping covers both surfaces, since nginx routes /api/ to the API and everything else to the dashboard. Right after a mapping is created, the *.style.dev proxy may briefly serve a “Reloading” warmup page (also a 200), so poll until Hexclave itself answers.
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 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.