---
title: "How to Run Hasura in a Sandbox"
description: "Run Hasura and Postgres in a VM, then open the Console on a public domain."
url: "/docs/guides/run-hasura-in-a-sandbox"
---

Hasura gives you an instant GraphQL API over Postgres, and it ships as a Docker image. So this guide starts from a snapshot that already has Docker installed — build one with [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox) — then boots a VM, brings up Hasura and its database with `docker compose`, and opens the Console on a public domain.


## 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 VM and Start Hasura

Boot a VM from your Docker snapshot — Docker is already running, so you can write a Compose file and bring the stack up right away. `dockerSnapshotId` is the snapshot id from [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox).

The stack has two services: a `postgres` database and the `graphql-engine` itself. Two things in the Compose file matter:

- The database service **must** be named `postgres` — Compose uses the service name as its hostname on the default network, and the connection URLs point at `@postgres:5432`. Rename the service and Hasura can't resolve its metadata database.
- YAML-quote the boolean-ish and asterisk values (`"true"`, `"*"`). Unquoted, YAML parses `true` as a boolean and `*` as an alias reference, and Compose rejects the file.

Containers run under the Docker daemon with `restart: always`, so the stack comes back on its own after a reboot — there is no systemd unit to write.

```ts
import { freestyle } from "freestyle";

const { vm, vmId } = await freestyle.vms.create({
  slug: "hasura-server",
  snapshotId: dockerSnapshotId,
  idleTimeoutSeconds: null,
});

await vm.exec("mkdir -p /root/hasura");
await vm.fs.writeTextFile(
  "/root/hasura/docker-compose.yml",
  `services:
  postgres:
    image: postgres:15
    restart: always
    environment:
      POSTGRES_PASSWORD: postgrespassword
    volumes:
      - db_data:/var/lib/postgresql/data
  graphql-engine:
    image: hasura/graphql-engine:v2.46.0
    ports:
      - "8080:8080"
    depends_on:
      - postgres
    restart: always
    environment:
      HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
      PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
      HASURA_GRAPHQL_DEV_MODE: "true"
      HASURA_GRAPHQL_ADMIN_SECRET: a-strong-secret
      HASURA_GRAPHQL_CORS_DOMAIN: "*"
volumes:
  db_data:
`,
);

await vm.exec("cd /root/hasura && docker compose up -d");
```

Hasura retries the metadata database connection on its own, so there is no healthcheck or wait-for-Postgres dance — just poll the unauthenticated `/healthz` endpoint until it answers `200` (ready in about nine seconds):

```ts
let code = "000";
while (code !== "200") {
  await new Promise((r) => setTimeout(r, 1500));
  code = (
    await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:8080/healthz")
  ).stdout.trim();
}

console.log((await vm.exec("curl -s localhost:8080/v1/version")).stdout);
// {"server_type":"ce","version":"v2.46.0"}
```


## Snapshot for Fast Boots

The image pull on first boot is the only slow part, and Hasura bakes no host-specific config — `HASURA_GRAPHQL_CORS_DOMAIN` is `*` and it answers on any `Host`. So once it's up, snapshot the VM with the stack **already running**, and every VM you boot from that snapshot comes up serving on `8080` with no pull and no `docker compose up`:

```ts
const { snapshotId } = await vm.snapshot();
// later — boot an already-serving Hasura in seconds:
// const { vm, vmId } = await freestyle.vms.create({ snapshotId });
```

A Freestyle snapshot captures the running containers (`restart: always` brings them back on boot) and the `db_data` volume, so the snapshot is a complete, ready-to-serve Hasura.


## Stream the Logs

`vm.exec()` buffers a command and only returns once it finishes, so it can't show the running containers' 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 both services; new lines stream in until you detach.
session.write("cd /root/hasura && docker compose logs -f\n");

// session.detach() drops your handle — the containers keep running.
```


## Open It on a Domain

Hasura publishes port `8080`, so route a [domain](https://www.freestyle.sh/docs/vms/domains) to it. Pick your own unique `*.style.dev` subdomain; it needs no DNS or verification.

```ts
const domain = `hasura-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8080 });

// The *.style.dev proxy may briefly serve a "Reloading" warmup page (also 200)
// right after mapping, so poll a couple of times until /healthz returns Hasura's "OK".
let body = "";
for (let i = 0; i < 5 && body !== "OK"; i++) {
  await new Promise((r) => setTimeout(r, 1500));
  body = (await (await fetch(`https://${domain}/healthz`)).text()).trim();
}
console.log(body); // OK

console.log(`https://${domain}/console`);
```

Open the printed `/console` URL in a browser and the Hasura Console loads and works over the domain — connect a database, define tables, and explore the API. The `/healthz` and `/v1/version` endpoints are unauthenticated, which makes them ideal health checks. The GraphQL endpoint itself lives at `/v1/graphql` and requires the `x-hasura-admin-secret: a-strong-secret` header you set in the Compose file.

One edge: a raw scripted GraphQL `POST` from a non-browser client over the public domain may be challenged by the edge proxy. The browser Console — and any app sending normal browser headers — goes straight through, so this only affects bare scripts. From inside the VM you can always hit `localhost:8080/v1/graphql` directly.
