---
title: "How to Run Hexclave in a Sandbox"
description: "Run Hexclave in a Docker-backed VM and open its API and dashboard on a public domain."
url: "/docs/guides/run-hexclave-in-a-sandbox"
---

[Hexclave](https://hexclave.com) (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](https://www.freestyle.sh/docs/guides/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

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
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.

```ts
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.

```ts
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.

```ts
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).

```ts
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:

```ts
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](https://www.freestyle.sh/docs/vms/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:

```ts
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.

```ts
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.
