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.
Why AerolVM fits
Section titled “Why AerolVM fits”creategives each workspace or customer its own isolated database runtime.- Environment variables let you inject credentials without baking them into the image.
createSessionkeeps 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.
Copy-paste TypeScript script
Section titled “Copy-paste TypeScript script”Set these environment variables before running the script:
SB_PAT_TOKENPOSTGRES_USERPOSTGRES_PASSWORD- Optional:
POSTGRES_DB(defaults toappdb) - Optional:
PUBLIC_AUTH_USERandPUBLIC_AUTH_PASSWORDfor 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 base64import jsonimport osimport subprocessfrom 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;});What the script gives you
Section titled “What the script gives you”deploy-your-own-postgres.jsonwith the sandbox ID, the running session IDs, the public HTTP admin URL, and a nativepostgresql://...DSN.- A Postgres server listening on port
5432inside the sandbox, published as atcp://<public-host>:<host-port>endpoint via caddy-l4. - A password-protected HTTP endpoint on port
3000that proves the database is up and returns basic metadata.
Connecting natively
Section titled “Connecting natively”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.