Freestyle Docs

Freestyle / Guides

How to Run Supabase in a Sandbox

Run the Supabase stack in a Docker-backed VM and reach its APIs on a public domain.

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

esc