PandaStack

Deploy an app with a managed database

Provision a managed PostgreSQL database, wire its credentials into an app as secrets, run migrations during the build, and ship the app behind a stable URL.

This guide takes you from nothing to a running app backed by its own PostgreSQL 16 database. You will create the database, read its connection details, inject them into the app as encrypted environment variables, run schema migrations as part of the deploy, and verify the result.

Every step uses the public REST API, so you can copy the commands as-is. Where the SDKs cover the same call, the SDK form is shown too.

Before you start

  • An API key. Export it once: export PANDASTACK_API_KEY=pds_...
  • An https clone URL for the repo you want to deploy (https://github.com/me/my-app). Private repos need a connected GitHub App installation.
  • A PostgreSQL client driver in your repo (pg, psycopg, Prisma, SQLAlchemy — anything that speaks postgres:// over TLS).

Managed databases are PostgreSQL only. There is no managed Redis and no managed object storage — if your app needs those, point it at an external provider through the same environment variables you set below.

Step 1 — Create the database

POST /v1/databases provisions a dedicated PostgreSQL 16 microVM. It returns 202 Accepted immediately with status: "provisioning" — PostgreSQL bootstrap takes another 30–60s after the VM is up, so the call does not block on it.

create-database.sh
curl -X POST https://api.pandastack.ai/v1/databases \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label":"my-app-db","size":"1g"}'
{
  "id": "3f95041e-0ba0-4068-8302-512e811edd60",
  "status": "provisioning",
  "template": "postgres-16",
  "size": "1g",
  "label": "my-app-db",
  "host": "3f95041e-0ba0-4068-8302-512e811edd60.db.pandastack.ai",
  "port": 5432,
  "broker_url": "https://api.pandastack.ai/v1/databases/3f95041e-.../proxy"
}

Create fields (all optional):

FieldDefaultNotes
labelHuman-friendly tag surfaced in list and get.
size1gRAM tier: 1g, 4g, or 16g. Anything else returns 400.
always_onfalseOpt out of idle auto-suspend. See Step 7.
cpu / memory_mb2 / 1024Recorded on the request, but the guest actually runs at the size baked into the tier's template — Firecracker cannot resize a snapshot-restored VM.

size is fixed at create time. To move to a different tier later you clone the database into the new size — there is no in-place resize.

Poll GET /v1/databases/{id} until status is running:

DB_ID=3f95041e-0ba0-4068-8302-512e811edd60
until curl -sf -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  "https://api.pandastack.ai/v1/databases/$DB_ID" | grep -q '"status":"running"'; do
  sleep 5
done

Both SDKs do the polling for you and return a ready database:

import pandastack

client = pandastack.Client(api_key="pds_...")
db = client.databases.create(label="my-app-db", size="1g")   # blocks until running
print(db["connection_url"])
import { Client } from "@pandastack/sdk";

const client = new Client({ apiKey: "pds_..." });
const db = await client.databases.create({ label: "my-app-db", size: "1g" });
console.log(db.connection_url);
CLI
pandastack db create --label my-app-db --size 1g

Step 2 — Read the connection details

Once the database is running, GET /v1/databases/{id} returns the credentials. GET /v1/databases/{id}/connection returns just the connection fields.

curl -s -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  "https://api.pandastack.ai/v1/databases/$DB_ID"
{
  "id": "3f95041e-0ba0-4068-8302-512e811edd60",
  "status": "running",
  "host": "3f95041e-0ba0-4068-8302-512e811edd60.db.pandastack.ai",
  "port": 5432,
  "database": "pandastack",
  "username": "pandastack",
  "password": "nUf0NrDb4zm_gQXBKhVRn-b77RKllFLq",
  "connection_url": "postgres://pandastack:...@3f95041e-....db.pandastack.ai:5432/pandastack",
  "broker_token": "pds_pg_M6rRbwNPp0Ed14PBEsTxw8ksDcX2jt7F",
  "broker_url": "https://api.pandastack.ai/v1/databases/3f95041e-.../proxy"
}

The five values your app needs are host, port, database, username, and password — or the single connection_url that combines them.

TLS is required. Traffic reaches your database by SNI on <id>.db.pandastack.ai, and a client that connects in plaintext is refused outright. The returned connection_url carries no sslmode parameter, so append one yourself (?sslmode=require) or set your driver's TLS option (ssl: true for node-postgres). The certificate chains to a public CA — you do not need a custom root certificate and you should not disable verification.

The password is returned only while the database is running. Store it where you keep secrets; if you lose it, rotate both credentials with POST /v1/databases/{id}/reset-credentials.

Step 3 — Create the app

POST /v1/apps stores the app's configuration. It does not build anything yet.

create-app.sh
curl -X POST https://api.pandastack.ai/v1/apps \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-app",
    "git_url": "https://github.com/me/my-app",
    "git_branch": "main",
    "port": 3000
  }'
app = client.apps.create(
    name="my-app",
    git_url="https://github.com/me/my-app",
    git_branch="main",
    port=3000,
)
const app = await client.apps.create({
  name: "my-app",
  git_url: "https://github.com/me/my-app",
  git_branch: "main",
  port: 3000,
});

Three facts about the runtime shape your configuration:

  • Apps run at a fixed 4 GiB / 8 vCPU. cpu and memory_mb are recorded on the app record but do not change what the app actually gets — the runtime size comes from the base template's baked snapshot.
  • The working directory is /app (plus root_directory if you set one for a monorepo).
  • The app's local disk is not durable. Files your app writes at runtime do not survive a hibernate/wake cycle or a deploy. That is exactly why persistent state belongs in the managed database.

Runtime versions come from a mise.toml or .tool-versions committed at the repo root. A .nvmrc is not honored:

mise.toml
[tools]
node = "22"

Step 4 — Wire the credentials into the app

Nothing is injected automatically except PORT and HOST — you set the database variables yourself, with PUT /v1/apps/{id}/env/{key}. Mark them secret so they are encrypted at rest, masked on read, and redacted from build logs.

Most drivers want a single URL:

set-database-url.sh
APP_ID=<app-id>
DB_URL=$(curl -s -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  "https://api.pandastack.ai/v1/databases/$DB_ID" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["connection_url"])')

curl -X PUT "https://api.pandastack.ai/v1/apps/$APP_ID/env/DATABASE_URL" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"value\":\"${DB_URL}?sslmode=require\",\"secret\":true,\"scope\":\"production\"}"

If your framework wants the parts separately, set them individually — the key names are yours to choose, so use whatever your code already reads:

set-discrete-vars.sh
set_secret() {
  curl -sS -X PUT "https://api.pandastack.ai/v1/apps/$APP_ID/env/$1" \
    -H "Authorization: Bearer $PANDASTACK_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"value\":\"$2\",\"secret\":true,\"scope\":\"production\"}"
}

set_secret PGHOST     "$DB_ID.db.pandastack.ai"
set_secret PGPORT     "5432"
set_secret PGDATABASE "pandastack"
set_secret PGUSER     "pandastack"
set_secret PGPASSWORD "<password from step 2>"
set_secret PGSSLMODE  "require"

Or bulk-import them from a .env-shaped string:

curl -X POST "https://api.pandastack.ai/v1/apps/$APP_ID/env/import" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"dotenv":"PGHOST=...\nPGUSER=pandastack","secret":true,"scope":"production"}'
client.apps.set_env(app["id"], "DATABASE_URL", db["connection_url"] + "?sslmode=require",
                    secret=True, scope="production")
await client.apps.setEnv(app.id, "DATABASE_URL", `${db.connection_url}?sslmode=require`, {
  secret: true,
  scope: "production",
});

Scope matters. scope: "production" keeps the production credentials out of PR preview deploys. If you use previews, give the repo a separate preview database through PUT /v1/repos/{repoID}/preview-env/DATABASE_URL so a pull request can never write to production data. Full precedence rules are in Environment variables & secrets.

Step 5 — Run migrations as part of the deploy

There is no separate release-command hook. Run migrations in the build command, which executes inside the fresh deploy sandbox with your env and secrets already loaded, before the health check and before traffic flips:

curl -X PATCH "https://api.pandastack.ai/v1/apps/$APP_ID" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"build_command":"npm run build && npx prisma migrate deploy"}'

The same pattern for other stacks:

Stackbuild_command
Prismanpm run build && npx prisma migrate deploy
Drizzlenpm run build && npx drizzle-kit migrate
Djangopython manage.py collectstatic --noinput && python manage.py migrate
Alembicalembic upgrade head
Rails-style / plain SQLpsql "$DATABASE_URL" -f migrations/latest.sql

Putting migrations here means a failed migration fails the deploy: the blue-green flip never happens and traffic keeps going to the previous, working sandbox.

The alternative is chaining onto the start command — {"start_command":"npx prisma migrate deploy && npm start"}. Shell operators are supported there. But the start command also runs every time the app wakes from scale-to-zero, so it re-runs the migration on every cold start and adds that latency to the first request. Use it only for genuinely idempotent commands, and prefer the build command.

Point migrations at the managed database, never at a file on the app's disk. A SQLite file or anything else written under /app is lost on the next deploy or wake.

Step 6 — Deploy

POST /v1/apps/{id}/deploys returns 202 Accepted with a queued deployment.

curl -X POST "https://api.pandastack.ai/v1/apps/$APP_ID/deploys" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
deploy = client.apps.deploy(app["id"])
for line in client.apps.deploy_logs(app["id"], deploy["id"]):
    print(line)
const deploy = await client.apps.deploy(app.id);
for await (const line of client.apps.deployLogs(app.id, deploy.id)) console.log(line);

A deployment moves through queuedbuildingdeployinglive, or failed. The pipeline has a 12-minute budget: it clones the ref, detects the framework, installs runtimes with mise, runs install and build (your migration runs here), starts the app detached, health-checks its port for up to 60s, then flips traffic and tears down the old sandbox.

Follow along with GET /v1/apps/{id}/deploys/{deployID}/logs (SSE), or watch the app's own stdout/stderr with GET /v1/apps/{id}/runtime-logs?follow=1.

Step 7 — Verify, and decide whether the database should auto-suspend

GET /v1/apps/{id} returns a computed url once the app is live:

curl -s -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  "https://api.pandastack.ai/v1/apps/$APP_ID"
# → "url": "https://<app-id>.<suffix>/"

curl -i "https://<app-id>.<suffix>/"

An idle database with no live connections suspends its compute and resumes on the next connection, which is what makes a per-branch or low-traffic database cheap. The visible cost is a slower first query after an idle stretch. If your workload must never pay that resume, keep it awake:

curl -X PATCH "https://api.pandastack.ai/v1/databases/$DB_ID" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"always_on": true}'
client.databases.update(db_id, always_on=True)
await client.databases.update(dbId, { always_on: true });

You can also set always_on at creation, or wake a database explicitly with POST /v1/databases/{id}/wake (rarely needed — connecting wakes it).

The whole thing, end to end

deploy-with-db.sh
#!/usr/bin/env bash
set -euo pipefail

API=https://api.pandastack.ai/v1
AUTH=(-H "Authorization: Bearer $PANDASTACK_API_KEY" -H "Content-Type: application/json")

# 1. Create the database (202, provisioning)
DB_ID=$(curl -sS -X POST "$API/databases" "${AUTH[@]}" \
  -d '{"label":"my-app-db","size":"1g"}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "database $DB_ID"

# 2. Wait for it, then read the connection URL
for _ in $(seq 60); do
  DB=$(curl -sS "${AUTH[@]}" "$API/databases/$DB_ID")
  [ "$(printf '%s' "$DB" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')" = running ] && break
  sleep 5
done
DB_URL=$(printf '%s' "$DB" | python3 -c 'import json,sys; print(json.load(sys.stdin)["connection_url"])')

# 3. Create the app
APP_ID=$(curl -sS -X POST "$API/apps" "${AUTH[@]}" -d '{
  "name":"my-app",
  "git_url":"https://github.com/me/my-app",
  "git_branch":"main",
  "port":3000,
  "build_command":"npm run build && npx prisma migrate deploy"
}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "app $APP_ID"

# 4. Inject the credential as a production secret (TLS required)
curl -sS -X PUT "$API/apps/$APP_ID/env/DATABASE_URL" "${AUTH[@]}" \
  -d "{\"value\":\"${DB_URL}?sslmode=require\",\"secret\":true,\"scope\":\"production\"}" >/dev/null

# 5. Deploy and follow the build
DEPLOY_ID=$(curl -sS -X POST "$API/apps/$APP_ID/deploys" "${AUTH[@]}" -d '{}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -sN "${AUTH[@]}" "$API/apps/$APP_ID/deploys/$DEPLOY_ID/logs"

# 6. Print the live URL
curl -sS "${AUTH[@]}" "$API/apps/$APP_ID" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin).get("url"))'

What can go wrong

SymptomCauseWhere to look
Create returns 402The free tier's $5.40/month credit is spent. Compute stops, the workspace is paused, and new creates are refused. Nothing is deleted — upgrading lifts the pause on the next reconcile (about a minute).402 Payment Required
The app URL returns 503The workspace is paused for the same reason.App URL returns 503
Create returns 502Fleet memory is the binding constraint; capacity is momentarily unavailable. Retry with backoff.502 Bad Gateway on create
POST .../wake returns 411A bodyless POST is rejected at the edge. Send -d '{}' with a JSON content type.411 Length Required
Driver reports a TLS or certificate errorThe client tried plaintext, or was told to skip verification. TLS is mandatory and the chain is public.TLS errors
connection_url missing from the responseThe database is suspended or still provisioning. Connect (which wakes it) or poll until running.Databases
Deploy reaches deploying then failsThe app started but never answered on its port. Bind 0.0.0.0 and use $PORT.Health check fails
Build dies partway with no errorThe build exceeded the app's 4 GiB.Build runs out of memory
Files the app wrote are goneThe app's local disk is not durable. Persist to the database.Files have disappeared
Migration fails, app keeps serving old codeIntended: a failed build command aborts the deploy before the blue-green flip. Fix and redeploy.Apps overview

Still stuck? Start at Troubleshooting, then Databases and Apps for the full reference on each side.

On this page