Skip to content

Deploy Your Own Postgres

This workflow boots a dedicated Postgres instance inside an AerolVM sandbox, initializes it from environment variables, and keeps the database alive in a long-running session. It also starts a tiny HTTP admin endpoint that checks the database and can be exposed on a public URL.

This is the Postgres foundation, not the full Supabase control plane. If you want a Supabase-style stack, add PostgREST, GoTrue, storage, realtime, and pooling on top of this database runtime.

  • create gives each workspace or customer its own isolated database runtime.
  • Environment variables let you inject credentials without baking them into the image.
  • createSession keeps both Postgres and an optional HTTP admin surface alive after setup finishes.
  • Lifecycle policies let you stop or destroy idle database sandboxes automatically.

Native Postgres endpoint via exposePort with protocol: "tcp"

Section titled “Native Postgres endpoint via exposePort with protocol: "tcp"”

This page exposes the Postgres wire protocol directly using exposePort(5432, { protocol: "tcp" }). The daemon allocates a parent-host port from the configured [22000, 23000] pool and points caddy-l4 at the container's 5432, so the returned result has a tcp://<public-host>:<host-port> URL and separate host / hostPort fields that work with psql, Prisma, SQLAlchemy, pgAdmin, and any other client that speaks the PostgreSQL wire protocol.

If you only need browser-shaped traffic (a small admin UI, REST shim, PostgREST), call exposePort() with no options - that's the HTTPS-only path documented in Preview. See TCP & TLS Ports for the full surface.

Set these environment variables before running the script:

  • SB_PAT_TOKEN
  • POSTGRES_USER
  • POSTGRES_PASSWORD
  • Optional: POSTGRES_DB (defaults to appdb)
  • Optional: PUBLIC_AUTH_USER and PUBLIC_AUTH_PASSWORD for the HTTP admin endpoint. If omitted, the script falls back to the Postgres credentials. In production, use separate values.

If SB_API_URL is unset, the script defaults to http://127.0.0.1:21212.

import { writeFile } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { MicroVM } from "@aerol-ai/aerolvm-sdk";
const apiUrl = process.env.SB_API_URL ?? "http://127.0.0.1:21212";
const patToken = process.env.SB_PAT_TOKEN;
const postgresUser = process.env.POSTGRES_USER || 'appuser';
const postgresPassword = process.env.POSTGRES_PASSWORD ?? 'password';
const postgresDB = process.env.POSTGRES_DB ?? "appdb";
const publicAuthUser = process.env.PUBLIC_AUTH_USER ?? postgresUser;
const publicAuthPassword = process.env.PUBLIC_AUTH_PASSWORD ?? postgresPassword;
const postgresPort = 5432;
const adminPort = 3000;
const pgData = "/workspace/postgres/data";
if (!patToken) {
throw new Error("Set SB_PAT_TOKEN before running this example.");
}
if (!postgresUser || !postgresPassword) {
throw new Error("Set POSTGRES_USER and POSTGRES_PASSWORD before running this example.");
}
if (!publicAuthUser || !publicAuthPassword) {
throw new Error(
"Set PUBLIC_AUTH_USER / PUBLIC_AUTH_PASSWORD or provide POSTGRES_USER / POSTGRES_PASSWORD.",
);
}
const databaseURL = `postgresql://${encodeURIComponent(postgresUser)}:${encodeURIComponent(postgresPassword)}@127.0.0.1:${postgresPort}/${encodeURIComponent(postgresDB)}`;
const adminServer = `import base64
import json
import os
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DATABASE_URL = os.environ["DATABASE_URL"]
AUTH_USER = os.environ["PUBLIC_AUTH_USER"]
AUTH_PASSWORD = os.environ["PUBLIC_AUTH_PASSWORD"]
PORT = int(os.environ.get("PORT", "3000"))
def expected_auth_header() -> str:
token = base64.b64encode(f"{AUTH_USER}:{AUTH_PASSWORD}".encode("utf-8")).decode("ascii")
return f"Basic {token}"
def query_database() -> bytes:
result = subprocess.run(
[
"psql",
DATABASE_URL,
"-Atqc",
"select json_build_object('database', current_database(), 'current_user', current_user, 'server_version', current_setting('server_version'), 'now', now())::text;",
],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip().encode("utf-8")
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/healthz":
body = b"ok\\n"
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if self.headers.get("Authorization") != expected_auth_header():
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="postgres-admin"')
self.end_headers()
return
try:
body = query_database()
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except subprocess.CalledProcessError as error:
body = json.dumps(
{"error": error.stderr.strip() or "psql failed"},
).encode("utf-8")
self.send_response(500)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args) -> None:
return
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
`;
function log(msg: string) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
async function waitForPostgres(sandbox: Awaited<ReturnType<MicroVM["create"]>>) {
log("Waiting for Postgres to accept connections...");
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await sandbox.exec({
command: 'sh -lc \'pg_isready -h 127.0.0.1 -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB"\'',
timeoutSeconds: 5,
});
if (result.exitCode === 0) {
log(`Postgres ready after ${attempt + 1} attempt(s)`);
return;
}
log(` attempt ${attempt + 1}/30: not ready yet (exitCode=${result.exitCode})`);
await delay(1000);
}
throw new Error("Postgres did not become ready in time.");
}
async function waitForAdmin(sandbox: Awaited<ReturnType<MicroVM["create"]>>, adminSessionID: string) {
log("Waiting for admin HTTP server on port 3000...");
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await sandbox.exec({
command:
'python3 -c "import urllib.request; urllib.request.urlopen(\\"http://127.0.0.1:3000/healthz\\").read()"',
timeoutSeconds: 5,
});
if (result.exitCode === 0) {
log(`Admin server ready after ${attempt + 1} attempt(s)`);
return;
}
log(` attempt ${attempt + 1}/20: not ready yet (exitCode=${result.exitCode}) ${result.stderr.trim()}`);
await delay(1000);
}
const sessionLog = await sandbox.sessionLog(adminSessionID);
console.error("[admin session log]\n" + new TextDecoder().decode(sessionLog));
throw new Error("The HTTP admin endpoint did not become ready in time.");
}
async function main() {
const client = new MicroVM({ apiUrl, patToken });
log(`Creating sandbox (image=postgres:16-bookworm cpu=2 mem=2048MB disk=12GB)...`);
const sandbox = await client.create({
image: "postgres:16-bookworm",
osUser: "root",
cpu: 2,
memoryMB: 2048,
diskGB: 12,
env: {
POSTGRES_DB: postgresDB,
POSTGRES_USER: postgresUser,
POSTGRES_PASSWORD: postgresPassword,
PUBLIC_AUTH_USER: publicAuthUser,
PUBLIC_AUTH_PASSWORD: publicAuthPassword,
PGDATA: pgData,
PORT: String(adminPort),
},
lifecycle: {
stopIfIdleFor: 30 * 60 * 1_000_000_000,
destroyIfIdleFor: 6 * 60 * 60 * 1_000_000_000,
},
});
log(`Sandbox created: ${sandbox.id}`);
log("Installing python3...");
const aptResult = await sandbox.exec({
command:
"apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates && rm -rf /var/lib/apt/lists/*",
timeoutSeconds: 240,
});
if (aptResult.exitCode !== 0) {
throw new Error(`apt-get failed (exitCode=${aptResult.exitCode}): ${aptResult.stderr}`);
}
log("python3 installed");
log("Creating workspace directories and uploading admin server script...");
await sandbox.exec("mkdir -p /workspace/postgres /workspace/admin");
await sandbox.uploadFile("/workspace/admin/postgres-admin.py", adminServer);
log("Admin script uploaded to /workspace/admin/postgres-admin.py");
log("Starting Postgres session...");
const postgresSession = await sandbox.createSession({
name: "postgres",
command: 'sh -lc \'exec docker-entrypoint.sh postgres -c listen_addresses="*" -p 5432\'',
});
log(`Postgres session started: ${postgresSession.id}`);
await waitForPostgres(sandbox);
log("Starting admin HTTP server session...");
const adminSession = await sandbox.createSession({
name: "postgres-admin",
command: "python3 /workspace/admin/postgres-admin.py",
workDir: "/workspace/admin",
env: {
DATABASE_URL: databaseURL,
POSTGRES_DB: postgresDB,
POSTGRES_USER: postgresUser,
POSTGRES_PASSWORD: postgresPassword,
PUBLIC_AUTH_USER: publicAuthUser,
PUBLIC_AUTH_PASSWORD: publicAuthPassword,
PORT: String(adminPort),
},
});
log(`Admin session started: ${adminSession.id}`);
await waitForAdmin(sandbox, adminSession.id);
log(`Exposing admin HTTP port ${adminPort}...`);
const adminExposure = await sandbox.exposePort(adminPort);
log(`Admin port exposed: ${adminExposure.url}`);
log(`Exposing Postgres port ${postgresPort} via TLS-SNI...`);
const postgresExposure = await sandbox.exposePort(postgresPort, { protocol: "tls" });
log(`Postgres TLS endpoint: ${postgresExposure.url}`);
if (postgresExposure.protocol !== "tls") {
throw new Error("expected tls exposure for Postgres");
}
// tls:// URL → extract host and port for tunnel instructions.
const tlsURL = new URL(postgresExposure.url.replace(/^tls:\/\//, "https://"));
const tlsHost = tlsURL.hostname;
const tlsPort = tlsURL.port || "443";
// Clients cannot connect directly - postgres wire protocol sends SSLRequest
// before the TLS ClientHello, so caddy-l4 never sees the SNI. Use a local
// TLS tunnel (socat or stunnel) that opens a real TLS socket to caddy and
// forwards the plaintext postgres stream through it. Connect with sslmode=disable.
//
// socat example (pick any free local port, e.g. 15432):
// socat TCP-LISTEN:15432,reuseaddr,fork \
// OPENSSL:${tlsHost}:${tlsPort},cafile=/etc/ssl/certs/ca-certificates.crt
// psql "postgresql://<user>:<pass>@127.0.0.1:15432/<db>?sslmode=disable"
//
// stunnel example (stunnel.conf):
// [postgres]
// client = yes
// accept = 127.0.0.1:15432
// connect = ${tlsHost}:${tlsPort}
// sni = ${tlsHost}
const tunnelDSN = `postgresql://${encodeURIComponent(postgresUser)}:${encodeURIComponent(postgresPassword)}@127.0.0.1:15432/${encodeURIComponent(postgresDB)}?sslmode=disable`;
const payload = {
sandboxID: sandbox.id,
postgresSessionID: postgresSession.id,
adminSessionID: adminSession.id,
adminURL: adminExposure.url,
postgresTLSEndpoint: postgresExposure.url,
tunnelDSN,
tunnelSetup: {
socat: `socat TCP-LISTEN:15432,reuseaddr,fork OPENSSL:${tlsHost}:${tlsPort},cafile=/etc/ssl/certs/ca-certificates.crt`,
stunnel: `[postgres]\nclient = yes\naccept = 127.0.0.1:15432\nconnect = ${tlsHost}:${tlsPort}\nsni = ${tlsHost}`,
},
database: {
hostInsideSandbox: "127.0.0.1",
port: postgresPort,
name: postgresDB,
userEnv: "POSTGRES_USER",
passwordEnv: "POSTGRES_PASSWORD",
},
note: "TLS is terminated by caddy-l4 using the wildcard domain cert. Connect via a local TLS tunnel (socat/stunnel) with sslmode=disable.",
};
await writeFile("deploy-your-own-postgres.json", `${JSON.stringify(payload, null, 2)}\n`);
log("Written deploy-your-own-postgres.json");
log(`To connect: run the socat tunnel, then psql "${tunnelDSN}"`);
console.log(JSON.stringify(payload, null, 2));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
  • deploy-your-own-postgres.json with the sandbox ID, the running session IDs, the public HTTP admin URL, and a native postgresql://... DSN.
  • A Postgres server listening on port 5432 inside the sandbox, published as a tcp://<public-host>:<host-port> endpoint via caddy-l4.
  • A password-protected HTTP endpoint on port 3000 that proves the database is up and returns basic metadata.

The postgresURL returned by the script is a real Postgres DSN. Plug it into any client:

psql "postgresql://user:pass@<public-host>:<host-port>/appdb"

The host port is one of the parent-host ports allocated from the configured [22000, 23000] pool. Make sure that range is open on the host firewall - see TCP & TLS Ports for the full surface and reconcile guarantees.