---
title: "How to Run Supabase in a Sandbox"
description: "Run the Supabase stack in a Docker-backed VM and reach its APIs on a public domain."
url: "/docs/guides/run-supabase-in-a-sandbox"
---

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](https://www.freestyle.sh/docs/guides/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

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

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

```ts
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.)

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

```ts
// ~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:

```ts
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 }`.

```ts
// 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](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 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.

```ts
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](https://www.freestyle.sh/docs/vms/network/vpcs) and reach it over a [VPN](https://www.freestyle.sh/docs/vms/network/vpns).
