---
title: "How to Run InstantDB in a Sandbox"
description: "Run InstantDB's self-hosting stack in a Docker-backed VM and expose its sync engine on a public domain."
url: "/docs/guides/run-instantdb-in-a-sandbox"
---

InstantDB is a real-time database with a sync engine, and its self-hosting setup is a `docker compose` project: a Clojure/JVM server, the dashboard, Postgres, and MinIO. Because the whole thing runs as containers, start from a snapshot that already has Docker installed — see [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox) — then boot a VM, clone Instant's compose project into it, and bring the stack up.


## 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 VM and Clone Instant

Boot a VM from your Docker snapshot. Pass `idleTimeoutSeconds: null` so the long-running stack is never paused, and destructure both the `vm` handle and its `vmId` — you need the id to route a domain later.

```ts
import { freestyle } from "freestyle";

const dockerSnapshotId = "<your-docker-snapshot-id>";

const { vm, vmId } = await freestyle.vms.create({
  slug: "instantdb-server",
  snapshotId: dockerSnapshotId,
  idleTimeoutSeconds: null,
});
```

Instant's self-hosting compose files live in its repo, which is large (~2,600 files). A single `vm.exec` that runs longer than a few minutes can hit the exec timeout, so kick the clone off in the background, write a marker file when it finishes, and poll for that marker.

```ts
// Clone in the background so a slow clone can't trip the exec timeout.
await vm.exec(
  "nohup sh -c 'git clone --depth 1 https://github.com/instantdb/instant.git " +
    "/root/instant && touch /root/clone.done' >/root/clone.log 2>&1 &",
);

let cloned = false;
for (let i = 0; i < 60 && !cloned; i++) {
  await new Promise((r) => setTimeout(r, 5000));
  cloned = (await vm.exec("test -f /root/clone.done && echo ok")).stdout?.includes("ok") ?? false;
}
console.log(cloned); // true
```


## Configure and Start the Stack

Instant reads its configuration from an `.env` file next to the compose project. Start from the shipped `.env.example`, then append the overrides the stack needs — `docker compose --env-file` lets later lines win, so each override takes precedence over the default above it.

The value that must be right up front is `INSTANT_BACKEND_URL`: it is the public URL the browser and SDK call, so set it to the `*.style.dev` domain you map in a moment. Cap the JVM heap with `JAVA_OPTS` so the server can't balloon, and give Postgres and MinIO real credentials.

```ts
const dir = "/root/instant/self-hosting";

// The domain the browser and SDK will hit. We need it now because the server bakes
// INSTANT_BACKEND_URL in at startup; we map it to the VM further down.
const domain = `instantdb-${crypto.randomUUID().slice(0, 8)}.style.dev`;

// Start from the example and append overrides — later lines win for --env-file.
const example = await vm.fs.readTextFile(`${dir}/.env.example`);
await vm.fs.writeTextFile(
  `${dir}/.env`,
  example +
    `
# --- overrides (later lines win) ---
INSTANT_BACKEND_URL=https://${domain}
INSTANT_DASHBOARD_URL=http://localhost:3000
POSTGRES_PASSWORD=${crypto.randomUUID()}
MINIO_ROOT_USER=instant
MINIO_ROOT_PASSWORD=${crypto.randomUUID()}
JAVA_OPTS=-Xmx2g -Xms512m
`,
);
```

Bring the stack up. On first run this pulls five images — the JVM server, the dashboard, Instant's **custom** `pg-hint-plan` Postgres (required; do not swap it for stock `postgres`), MinIO, and a one-shot `createbuckets` job that seeds storage and then exits — so give it room.

```ts
await vm.exec({
  command: `cd ${dir} && docker compose --env-file .env up -d`,
  timeoutMs: 600_000,
});
```

The Clojure server cold-starts the JVM, which takes roughly 30-60 seconds, so poll its `/health` endpoint on port 8888 until it returns `200`.

```ts
let healthy = false;
for (let i = 0; i < 60 && !healthy; i++) {
  await new Promise((r) => setTimeout(r, 2000));
  const res = await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:8888/health");
  healthy = res.stdout?.trim() === "200";
}
console.log(healthy); // true
```


## Snapshot for Fast Boots

The InstantDB images — the JVM server and the custom `pg-hint-plan` Postgres — are large, so the first pull dominates startup. Snapshot the VM once they're cached to skip it on every future boot:

```ts
const { snapshotId } = await vm.snapshot();
```

Boot new instances from that `snapshotId`, set `INSTANT_BACKEND_URL` to the new domain in `.env`, and `docker compose --env-file .env up -d` — with local images you're only waiting on the JVM's ~30–60 s cold start, not the download.


## 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 — which is exactly what you want while the JVM cold-starts. To watch the logs live, 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 project with `docker compose logs -f` (or a single service with `docker compose logs -f server`). `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 every container in the stack; the server logs "Finished initializing" and
// "port=8888" once the JVM is ready.
session.write("cd /root/instant/self-hosting && docker compose logs -f\n");

// session.detach() drops your handle — the containers keep running.
```


## Open It on a Domain

The server publishes port `8888` on the VM, and that single port is both the SDK's `apiURI` and its realtime WebSocket host — so it is the only one you expose. Map your `domain` to it; the dashboard on port `3000` stays internal. Any `*.style.dev` subdomain works with no DNS or verification.

```ts
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8888 });

// The *.style.dev proxy can briefly serve a "Reloading" warmup page (still HTTP 200)
// right after a mapping is created, so poll until the real health check answers.
let live = false;
for (let i = 0; i < 5 && !live; i++) {
  const res = await fetch(`https://${domain}/health`);
  live = res.status === 200 && !(await res.text()).includes("Reloading");
  if (!live) await new Promise((r) => setTimeout(r, 2000));
}
console.log(live); // true
```


## Open the Dashboard

Instant's dashboard — where you create an app and copy its **App ID** — runs inside the VM on port `3000`, separate from the `8888` API. It's an admin UI, so reach it privately by port-forwarding over [SSH](https://www.freestyle.sh/docs/vms/ssh) instead of publishing it. Mint a token for the VM and forward the port to your machine:

```ts
const { identity } = await freestyle.identities.create();
await identity.permissions.vms.grant({ vmId });
const { token } = await identity.tokens.create();

console.log(`ssh -N -L 3000:localhost:3000 ${vmId}:${token}@vm-ssh.freestyle.sh`);
```

Run that `ssh -L` command and open `http://localhost:3000`. Sign in with any email — with no Postmark token configured the magic-code OTP isn't emailed but printed to the server logs, so read it with `docker compose logs server` (or follow them live, as in the previous section). Then create an app and copy its **App ID** for the client below.

To share the dashboard instead, map a throwaway `*.style.dev` domain to port `3000` — but prefer the tunnel for an admin UI.


## Connect a Client

Point the Instant SDK at your domain. The published port serves both the HTTP API (`apiURI`) and the realtime WebSocket (`websocketURI`), which lives at `/runtime/session`:

```ts
import { init } from "@instantdb/react";

const db = init({
  appId: "<your-app-id>",
  apiURI: `https://${domain}`,
  websocketURI: `wss://${domain}/runtime/session`,
});
```

Get the `appId` from the [dashboard](#open-the-dashboard) above — create an app there and copy its **App ID**.
