---
title: "How to Run Convex in a Sandbox"
description: "Run the self-hosted Convex backend in a Docker-backed VM and connect a Convex project to it."
url: "/docs/guides/run-convex-in-a-sandbox"
---

Convex is a reactive backend — a document database, server functions, and live queries — and it self-hosts as a pair of 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, write the official `docker compose` stack, and bring Convex up serving on port `3210`.


## 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 Start Convex

Boot a VM from the Docker snapshot — the daemon is already running, so `docker compose` works immediately. Convex advertises its own public URL to clients, so it has to know that URL **before** it first starts: pick the `*.style.dev` subdomain now, bake it into `.env`, then bring the stack up. Changing the origins later requires `docker compose up --force-recreate`, so it's worth getting right on the first boot.

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

// A snapshot from the Docker guide — Docker + Compose are installed and the daemon is running.
const dockerSnapshotId = "your-docker-snapshot-id";

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

// Convex bakes this public URL into what it tells clients, so choose it before the first boot.
// Reuse the same value when you map the domain below.
const domain = `convex-${crypto.randomUUID().slice(0, 8)}.style.dev`;

// INSTANCE_SECRET seeds the admin key — generate it once and keep it stable across reboots.
const instanceSecret = (await vm.exec("openssl rand -hex 32")).stdout.trim();
```

Download the official compose file and write the matching `.env` next to it. The backend reads these origins on its very first start, so write the file **before** running `up`:

```ts
await vm.exec("mkdir -p /root/convex");
await vm.exec(
  "curl -Lo /root/convex/docker-compose.yml https://raw.githubusercontent.com/get-convex/convex-backend/main/self-hosted/docker/docker-compose.yml",
);

await vm.fs.writeTextFile(
  "/root/convex/.env",
  `INSTANCE_NAME=convex-self-hosted
INSTANCE_SECRET=${instanceSecret}
CONVEX_CLOUD_ORIGIN=https://${domain}
CONVEX_SITE_ORIGIN=https://${domain}
NEXT_PUBLIC_DEPLOYMENT_URL=https://${domain}
`,
);
```

Bring the stack up. This pulls `ghcr.io/get-convex/convex-backend:latest` and `convex-dashboard:latest`, so give it room. Then wait for the backend's health endpoint — only the backend on port `3210` needs to be reachable; the dashboard on `6791` stays internal to the VM.

```ts
await vm.exec({
  command: "cd /root/convex && docker compose up -d",
  timeoutMs: 600_000,
});

// Health is HTTP 200 on /version. The body is the literal text "unknown", not JSON —
// treat the 200 as ready and don't try to parse it.
let code = "000";
while (code !== "200") {
  await new Promise((r) => setTimeout(r, 2000));
  code = (
    await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:3210/version")
  ).stdout.trim();
}
```

The containers carry a restart policy and the Docker daemon supervises them, so there's no systemd unit to write — they come back on their own if the VM reboots.


## Snapshot for Fast Boots

Pulling `convex-backend` and `convex-dashboard` is the slow part of the first boot. Once they're cached, snapshot the VM so future instances skip the download:

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

Boot new VMs from that `snapshotId`, write `.env` with that instance's domain (the origins are baked per VM), and run `docker compose up -d` — the images are already local, so the backend is up in seconds. Keep the same `INSTANCE_SECRET` and the admin key you generated stays valid.


## Open It on a Domain

Map the subdomain you picked above to the backend's port `3210`. It needs no DNS or verification.

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

Fetch `/version` from outside the VM to confirm. Right after mapping, the `*.style.dev` proxy may return a "Reloading" warmup page (still HTTP 200) for a moment, so poll a couple of times until the backend's own response comes through:

```ts
let body = "";
for (let i = 0; i < 10; i++) {
  const res = await fetch(`https://${domain}/version`);
  body = await res.text();
  if (res.status === 200 && !body.includes("Reloading")) break;
  await new Promise((r) => setTimeout(r, 2000));
}
console.log(body); // unknown
```


## Generate the Admin Key

The CLI and dashboard authenticate with an admin key, derived from `INSTANCE_SECRET`. Generate it once on the VM — it stays valid as long as the secret does:

```ts
const out = await vm.exec(
  "cd /root/convex && docker compose exec backend ./generate_admin_key.sh",
);
console.log(out.stdout);

// The key is the line that starts with the instance name.
const adminKey = out.stdout
  .split("\n")
  .map((l) => l.trim())
  .find((l) => l.startsWith("convex-self-hosted"))!;
```

Hold on to `adminKey` — you'll paste it into your project's environment next.


## Open the Dashboard

The self-hosted dashboard runs on port `6791`, separate from the backend, and it's an **admin** UI — so rather than publish it, reach it privately by port-forwarding over [SSH](https://www.freestyle.sh/docs/vms/ssh). Mint a token for the VM and forward the port to your machine:

```ts
// Grant an identity access to the VM and mint an SSH token.
const { identity } = await freestyle.identities.create();
await identity.permissions.vms.grant({ vmId });
const { token } = await identity.tokens.create();

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

Run that `ssh -L` command, then open `http://localhost:6791`. The dashboard is preconfigured with the deployment URL (it was baked in via `NEXT_PUBLIC_DEPLOYMENT_URL`), so just paste the `adminKey` to sign in — and nothing is exposed publicly; the tunnel stays on your machine.

If you'd rather share it, map a second domain straight to the dashboard port instead — but that puts the admin UI on the public internet behind only the admin key, so prefer the tunnel:

```ts
const dashboardDomain = `convex-dashboard-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain: dashboardDomain, vmId, vmPort: 6791 });
console.log(`Dashboard: https://${dashboardDomain}  (sign in with the admin key)`);
```


## Connect a Convex Project

From your Convex project on your own machine, install the client and point it at the self-hosted backend instead of Convex Cloud. Install the package:

```bash
npm install convex@latest
```

Write `.env.local` with the public URL and the admin key you just generated:

```bash .env.local
CONVEX_SELF_HOSTED_URL=https://convex-xxxxxxxx.style.dev
CONVEX_SELF_HOSTED_ADMIN_KEY=convex-self-hosted|paste-the-generated-key-here
```

Then run the dev command — it pushes your functions to the sandbox's backend and watches for changes:

```bash
npx convex dev
```

Your queries and mutations now run against Convex in the sandbox, and your app's client talks to it over the `*.style.dev` domain.


## Stream the Logs

`vm.exec()` buffers a command and only returns once it finishes, so it can't show a container's output as it happens. 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 stack 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 both containers; new lines stream in until you detach.
session.write("cd /root/convex && docker compose logs -f\n");

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