Supabase is a whole backend in one Docker Compose stack — Postgres, the PostgREST data API, GoTrue auth, Realtime, Storage, and the Studio dashboard, all fronted by a Kong API gateway on a single port. It runs via Docker, so start from a snapshot that already has Docker installed by following How to Run Docker in a Sandbox. This guide boots a VM from that snapshot, pulls the official stack, brings it up, and routes a public domain to it.
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 Bigger VM from the Docker Snapshot
Create a VM from the Docker snapshot and resize it first. Supabase is a heavy stack: roughly 4 GB of images across eleven services, wanting 2–4 GB of RAM once everything is running. The default VM (4 vCPU / 8 GB / 20 GB) is too tight for that, so grow it before doing any work — cpu and memory must be powers of two, and memory and storage are in GB.
import { freestyle } from "freestyle";
// dockerSnapshotId comes from the Docker guide linked above.
const { vm, vmId } = await freestyle.vms.create({
slug: "supabase-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
// Give the stack headroom — ~4GB of images and 2-4 GB of runtime RAM.
await vm.resize({ cpu: 4, memory: 16, storage: 60 }); // memory and storage in GB
// resize reboots the VM, but Docker is enabled in the snapshot, so dockerd comes
// back on boot. Wait for it before using it.
for (let i = 0; i < 30; i++) {
const r = await vm.exec("docker info >/dev/null 2>&1 && echo READY || echo WAIT");
if (r.stdout?.includes("READY")) break;
await new Promise((res) => setTimeout(res, 2000));
}
// Pick the domain now — the .env needs it, and you'll map it at the end.
const domain = `supabase-${crypto.randomUUID().slice(0, 8)}.style.dev`;
Fetch the Supabase Stack
The Compose files and the .env.example ship in the Supabase repo, so clone it and copy the docker/ directory into a working folder. Both this clone and the image pull later are large, which forces the one practical detail of this guide: a single vm.exec() aborts after about five minutes (a client headers timeout), and these steps run longer. So run them detached — write the work into a shell script, launch it with setsid so it outlives the exec that started it, and poll a marker file for completion with short exec calls.
const job = "/root/jobs";
await vm.exec(`mkdir -p ${job}`);
// Run a long command detached and poll its marker file until it finishes.
async function runDetached(name: string, script: string) {
await vm.fs.writeTextFile(
`${job}/${name}.sh`,
`#!/bin/sh
( set -e
${script}
) > ${job}/${name}.log 2>&1
echo $? > ${job}/${name}.done
`,
);
await vm.exec(`rm -f ${job}/${name}.done`);
// setsid puts the job in its own session so it survives the exec returning.
await vm.exec(`setsid sh ${job}/${name}.sh >/dev/null 2>&1 </dev/null &`);
for (let i = 0; i < 240; i++) {
await new Promise((res) => setTimeout(res, 5000));
const code = (await vm.exec(`cat ${job}/${name}.done 2>/dev/null || true`)).stdout.trim();
if (code === "0") return;
if (code) {
const log = (await vm.exec(`tail -n 20 ${job}/${name}.log`)).stdout;
throw new Error(`${name} failed (exit ${code}):\n${log}`);
}
}
throw new Error(`${name} timed out`);
}
// Clone the repo and lay down the compose files + .env (git may not be in the snapshot).
await runDetached(
"fetch",
`apt-get update -qq
apt-get install -y -qq git ca-certificates
git clone --depth 1 https://github.com/supabase/supabase /root/supabase-src
mkdir -p /root/supabase
cp -rf /root/supabase-src/docker/* /root/supabase/
cp /root/supabase-src/docker/.env.example /root/supabase/.env`,
);
Generate Secrets and Configure the Environment
The stack needs its own JWT secret and matching keys. Supabase ships helper scripts for this: generate-keys.sh writes a fresh JWT secret, the matching ANON_KEY / SERVICE_ROLE_KEY, and a random dashboard password into .env, and add-new-auth-keys.sh adds the newer publishable/secret API keys. Run them in that order. Then point every public URL at the domain you picked above, name the dashboard user, and capture the generated ANON_KEY and DASHBOARD_PASSWORD — you need them to call the APIs and to log in to Studio. (Skip the optional Logflare/analytics override; leaving it out saves a chunk of RAM.)
async function setEnv(key: string, value: string) {
// Each key already exists in .env.example; replace its line in place.
await vm.exec(`sed -i "s|^${key}=.*|${key}=${value}|" /root/supabase/.env`);
}
async function getEnv(key: string) {
const r = await vm.exec(`grep -E "^${key}=" /root/supabase/.env | head -n1 | cut -d= -f2-`);
return r.stdout.trim();
}
// Generate secrets — order matters.
await vm.exec("cd /root/supabase && sh utils/generate-keys.sh --update-env");
await vm.exec("cd /root/supabase && sh utils/add-new-auth-keys.sh --update-env");
// Point the public URLs at the domain, and name the dashboard user.
await setEnv("SUPABASE_PUBLIC_URL", `https://${domain}`);
await setEnv("API_EXTERNAL_URL", `https://${domain}`);
await setEnv("SITE_URL", `https://${domain}`);
await setEnv("DASHBOARD_USERNAME", "supabase");
// Capture the generated secrets — needed to call the APIs and log in to Studio.
const anonKey = await getEnv("ANON_KEY");
const dashboardPassword = await getEnv("DASHBOARD_PASSWORD");
console.log({ anonKey, dashboardPassword });
Pull and Start the Stack
Pull the images first. That’s the ~4 GB download — well over the five-minute exec limit — so run it detached with the same helper. Then bring the stack up with docker compose up -d: it returns once the containers are scheduled, but the first boot runs the Postgres initialization (2–3 minutes) before the APIs answer, so poll until the gateway proxies a healthy service.
// ~4GB across eleven services — detach and poll.
await runDetached("pull", "cd /root/supabase && docker compose pull");
// Bring the stack up. Local images now, so this returns quickly.
await vm.exec({
command: "cd /root/supabase && docker compose up -d",
timeoutMs: 240_000,
});
// Wait out the Postgres init: poll Kong until GoTrue answers 200.
// The apikey header is required (Kong rejects requests without it — see below).
let code = "";
for (let i = 0; i < 60 && code !== "200"; i++) {
await new Promise((res) => setTimeout(res, 5000));
code = (
await vm.exec(
`curl -s -o /dev/null -w '%{http_code}' -H "apikey: ${anonKey}" http://localhost:8000/auth/v1/health`,
)
).stdout.trim();
}
console.log(code); // 200
Snapshot for Fast Boots
The ~4 GB docker compose pull is by far the slowest step. Snapshot the VM once the images are cached so you never pay that download again:
const { snapshotId } = await vm.snapshot();
Boot future instances from that snapshotId (resized the same way), point the .env URLs at the new domain, and docker compose up -d with the images already local — first start is then just the ~2–3 min Postgres init, not a multi-gigabyte download.
Check the APIs
Kong is the single entry point on port 8000 and routes by path prefix: /auth/v1 to GoTrue, /rest/v1 to PostgREST, plus /realtime/v1 and /storage/v1. Every API request must carry the apikey header — Kong rejects anything without it, which is the gatekeeper working, not a failure. Note that /auth/v1/health returns GoTrue’s identity JSON (name and version), not a generic { ok: true }.
// With the apikey header: GoTrue's health/identity JSON.
const health = await vm.exec(
`curl -s -H "apikey: ${anonKey}" http://localhost:8000/auth/v1/health`,
);
console.log(health.stdout);
// {"version":"v2.189.0","name":"GoTrue","description":"GoTrue is a user registration and authentication API"}
// Without it: Kong returns 401 — expected, not a failure.
const noKey = await vm.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/auth/v1/health",
);
console.log(noKey.stdout.trim()); // 401 — "No API key found in request"
// PostgREST serves its OpenAPI schema at /rest/v1/ with the apikey header.
const rest = await vm.exec(
`curl -s -o /dev/null -w '%{http_code}' -H "apikey: ${anonKey}" http://localhost:8000/rest/v1/`,
);
console.log(rest.stdout.trim()); // 200
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. 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 every service in the stack; new lines stream in until you detach.
session.write("cd /root/supabase && docker compose logs -f\n");
// session.detach() drops your handle — the containers keep running.
Open It on a Domain
Map the domain you picked earlier to Kong on port 8000 — that one mapping covers everything, since Studio, REST, Auth, Realtime, and Storage all sit behind the gateway. Right after a mapping is created, the *.style.dev proxy may briefly serve a “Reloading” warmup page (also a 200), so poll a couple of times until Supabase itself answers.
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8000 });
// The proxy may serve a warmup page for a moment after mapping — poll until GoTrue answers.
let body = "";
for (let i = 0; i < 10; i++) {
const res = await fetch(`https://${domain}/auth/v1/health`, {
headers: { apikey: anonKey },
});
body = await res.text();
if (body.includes("GoTrue")) break;
await new Promise((r) => setTimeout(r, 2000));
}
console.log(body); // {"version":"v2.189.0","name":"GoTrue",...}
console.log(`Studio: https://${domain} (user "supabase", password ${dashboardPassword})`);
Studio is served at / behind Kong’s HTTP basic auth, so opening https://${domain} prompts for the DASHBOARD_USERNAME / DASHBOARD_PASSWORD you set above. One thing the domain does not expose is the raw Postgres wire protocol: the session port (5432) and the pooled port (6543) are TCP, while a *.style.dev domain only proxies HTTP(S). App clients should talk to Postgres through the REST, Auth, Realtime, and Storage APIs on port 8000, authenticated with the anon and service-role keys. If you need a direct database connection, put the VM on a VPC and reach it over a VPN.