Freestyle Docs

Freestyle / Guides

How to Run InstantDB in a Sandbox

Run InstantDB's self-hosting stack in a Docker-backed VM and expose its sync engine on a public domain.

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 — then boot a VM, clone Instant’s compose project into it, and bring the stack up.

Install the SDK

pnpm add freestyle
bun add freestyle
npm install freestyle
yarn add freestyle

Set your API key before calling the API:

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.

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.

// 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.

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.

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.

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:

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

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.

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 instead of publishing it. Mint a token for the VM and forward the port to your machine:

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:

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 above — create an app there and copy its App ID.

esc