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 freestylebun add freestylenpm install freestyleyarn add freestyle 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 *.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.
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:
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: 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:
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.
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:
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.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:
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:
npm install convex@latest
Write .env.local with the public URL and the admin key you just generated:
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:
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 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.