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 speakspostgres://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.
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):
| Field | Default | Notes |
|---|---|---|
label | — | Human-friendly tag surfaced in list and get. |
size | 1g | RAM tier: 1g, 4g, or 16g. Anything else returns 400. |
always_on | false | Opt out of idle auto-suspend. See Step 7. |
cpu / memory_mb | 2 / 1024 | Recorded 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
doneBoth 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);pandastack db create --label my-app-db --size 1gStep 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.
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.
cpuandmemory_mbare recorded on the app record but do not change what the app actually gets — the runtime size comes from thebasetemplate's baked snapshot. - The working directory is
/app(plusroot_directoryif 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:
[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:
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_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:
| Stack | build_command |
|---|---|
| Prisma | npm run build && npx prisma migrate deploy |
| Drizzle | npm run build && npx drizzle-kit migrate |
| Django | python manage.py collectstatic --noinput && python manage.py migrate |
| Alembic | alembic upgrade head |
| Rails-style / plain SQL | psql "$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 queued → building → deploying → live, 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
#!/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
| Symptom | Cause | Where to look |
|---|---|---|
Create returns 402 | The 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 503 | The workspace is paused for the same reason. | App URL returns 503 |
Create returns 502 | Fleet memory is the binding constraint; capacity is momentarily unavailable. Retry with backoff. | 502 Bad Gateway on create |
POST .../wake returns 411 | A bodyless POST is rejected at the edge. Send -d '{}' with a JSON content type. | 411 Length Required |
| Driver reports a TLS or certificate error | The client tried plaintext, or was told to skip verification. TLS is mandatory and the chain is public. | TLS errors |
connection_url missing from the response | The database is suspended or still provisioning. Connect (which wakes it) or poll until running. | Databases |
Deploy reaches deploying then fails | The app started but never answered on its port. Bind 0.0.0.0 and use $PORT. | Health check fails |
| Build dies partway with no error | The build exceeded the app's 4 GiB. | Build runs out of memory |
| Files the app wrote are gone | The app's local disk is not durable. Persist to the database. | Files have disappeared |
| Migration fails, app keeps serving old code | Intended: 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.