Freestyle Docs

Freestyle / Guides

How to Run Convex in a Sandbox

Run the self-hosted Convex backend in a Docker-backed VM and connect a Convex project to it.

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), then boot a VM, write the official docker compose stack, and bring Convex up serving on port 3210.

Install the SDK

pnpm add freestyle@latest
bun add freestyle@latest
npm install freestyle@latest
yarn add freestyle@latest

Set your API key before calling the API:

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 domain now — any unused subdomain of style.dev, or one of your own you have verified and pointed at Freestyle — 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.

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

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

const { vm, vmId } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  slug: vmSlug,
  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 route the domain below.
const domain = "convex.style.dev"; // any unused style.dev subdomain

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

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.

await vm.exec({
  command: "cd /root/convex && docker compose up -d",
  timeoutMs: 300_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:

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

Route the subdomain you picked above to the backend’s port 3210. A style.dev name needs no DNS or verification.

await freestyle.tls.rules.create({
  action: "allow",
  domain,
  source: { public: true },
  destination: { vmId, port: 3210 },
});

Fetch /version from outside the VM to confirm. Right after the rule is created, the Freestyle 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:

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:

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

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

console.log(`ssh -N -L 6791:localhost:6791 ${vmSlug}:${token}@beta-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, route 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:

const dashboardDomain = "convex-dashboard.style.dev"; // any unused style.dev subdomain
await freestyle.tls.rules.create({
  action: "allow",
  domain: dashboardDomain,
  source: { public: true },
  destination: { vmId, port: 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:

npm install convex@latest

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

CONVEX_SELF_HOSTED_URL=https://convex.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:

npx convex dev

Your queries and mutations now run against Convex in the sandbox, and your app’s client talks to it over the domain you routed.

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

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