Skip to content

Managed databases

Pro-plan projects choose Postgres 17 or MariaDB 12.3 LTS and provision isolated databases for Production, Preview, and Dev. The engine choice becomes immutable when the first branch is created. The platform injects the current channel's DATABASE_URL into your app automatically, SQL migrations ship with your deploys, and the dashboard includes a channel-aware Database Studio for browsing, querying, usage, migrations, and backups.

Pro plan required

Managed databases are gated by your plan's database allowance. On the Free plan, apco db enable fails with PLAN_FEATURE_UNAVAILABLE (HTTP 403). Your maxDatabases allowance counts database-enabled projects, not channel branches: one project with all three databases consumes one slot. Storage and connection limits apply independently to each branch. See Plans & quotas.

Enabling a database

Databases are for SSR apps only — static sites can't connect to anything at runtime, and a manifest with type: static + database.enabled: true is rejected as invalid.

bash
apco db enable --engine postgres # Postgres 17 (default); waits until ready
apco db enable --engine mariadb  # MariaDB 12.3 LTS instead
apco db enable --no-wait  # return as soon as provisioning is queued
apco db branch preview --engine mariadb # choose engine if this is the first branch
apco db branch dev      # explicitly provision Dev
Enabling database for my-app...
Waiting for database to be ready...
Database for my-app is ready.

Or declare it in apco.yml and let apco deploy handle it:

yaml
version: 2
app:
  name: my-app
  type: ssr
  port: 3000
database:
  enabled: true
  engine: mariadb # postgres (default) | mariadb; fixed after the first branch
release:
  command: pnpm db:migrate
  timeoutSeconds: 300

With database.enabled: true, normal full-access deploy flows ensure the target channel database and wait for it before uploading, so the container boots with the matching DATABASE_URL even on its first deployment. Explicit apco deploy --project and narrow CI credentials never provision infrastructure; prepare their target with apco db branch <channel>.

All branches of one project use the same engine. A later manifest, CLI, MCP, or API request for the other engine fails with DATABASE_ENGINE_CONFLICT; deleting every branch does not reset the choice. Existing projects and manifests that omit database.engine remain Postgres.

The optional release.command is the safest deploy-coupled migration hook: it runs once after build and before traffic against this exact channel's DATABASE_URL. Failure keeps the previous application deployment serving. See Release commands. Continue to use forward-compatible expand/contract migrations—application rollback skips the historical release command and does not reverse schema changes.

DATABASE_URL is injected for you

When a channel database is ready, deployments on that channel receive its DATABASE_URL automatically. Production, Preview, and Dev never fall back to one another:

  • It overrides any DATABASE_URL you set yourself in env vars.
  • It is never returned by apco db status, the API, or MCP tools.
  • Tenant URLs remain IP-based inside gVisor; the isolation boundary is a separate physical database and login role for every channel.

Postgres apps can use the @apco/db SDK (a lightweight postgres.js client):

ts
import { createDb } from "@apco/db";

const db = createDb(); // reads process.env.DATABASE_URL
const rows = await db.sql`SELECT * FROM todos ORDER BY created_at DESC`;

MariaDB URLs use the mysql:// scheme. Use any MySQL-compatible client—for example mysql2, Drizzle's MySQL dialect, Prisma's MySQL provider, or your framework's MariaDB driver. @apco/db and the Postgres realtime adapter remain Postgres-specific.

Branch lifecycle and promotion

There is one persistent database per channel, not one per deployment. Replacing a Preview or Dev deployment keeps that channel's schema and data. Only apco db disable --channel <channel>, project deletion, or account deletion removes a branch.

apco promote is code-only with respect to data. It starts the Preview artifact as a Production deployment and injects Production's database. It never copies or merges Preview data. A configured release command runs as a fresh Production phase against Production data before traffic switches. If Production is missing or unready, promotion returns DATABASE_NOT_READY; prepare it with apco db branch production.

Status, size, and limits

bash
apco db status                         # Production (default)
apco db status --channel preview
Database for my-app: ready
json
{ "ok": true, "operation": "db.status", "channel": "preview",
  "engine": "mariadb", "enabled": true, "status": "ready",
  "sizeBytes": 1048576, "sizeLimitMb": 512, "connectionLimit": 10 }

The size limit (sizeLimitMb) and connection limit come from your plan and apply independently to the selected branch. Watch sizeBytes against the limit as your data grows — or check the Database Studio's Usage card, which shows a live storage bar, active connections, and a size-history sparkline. Programmatically, GET /projects/:id/database/usage?channel=preview returns the same live snapshot — current size, read-only state, active connections vs. the limit, and up to 30 recent size samples:

json
{ "ok": true, "sizeBytes": 10485760, "sizeLimitMb": 512, "readOnly": false,
  "connections": { "active": 2, "limit": 10 }, "history": [ "..." ] }

Over quota (read-only)

Nightly housekeeping compares each channel database's live size against your current plan's dbSizeMb limit — always the live plan, never a stale snapshot, so a plan change is picked up on the next run. Go over it and the selected database is set read-only: existing connections are terminated, reads keep working, and writes fail with the selected engine's permission or read-only error.

readOnly: true on the selected channel's usage response tells you when this is active. There is no manual unblock — free up space (delete data) or upgrade your plan, and the next housekeeping run restores writes automatically.

Migrations

SQL migrations live in .apco/migrations/*.sql, run in filename order:

.apco/migrations/
  001_todos.sql
  002_users.sql
  003_add_index.sql

Apply them from your machine:

bash
apco db migrate --channel production
apco db migrate --channel preview
apco db migrate --channel dev
Applied 1 migration(s), skipped 2 already applied.

How it works:

  • Idempotent — applied files are tracked in the _apco_migrations table; re-running only applies new files.
  • Engine-aware — Postgres applies each file and its tracking insert in one transaction. MariaDB uses one ordered client session, but arbitrary DDL may commit implicitly, so a failed file may need manual repair before retrying.
  • Ships with deploys.apco/migrations/ is packed into every upload. Postgres apps can also run migrations at boot via runMigrations from @apco/db/migrate (same tracking table):
ts
import { createDb } from "@apco/db";
import { runMigrations } from "@apco/db/migrate";

const db = createDb();
await runMigrations(db.sql, ".apco/migrations");

For app-boot migrations, the runtime migrator adds safety and observability:

ts
await runMigrations(db.sql, ".apco/migrations", {
  onProgress: (event) => console.log(`[migrate] ${event.type}`),
});

The Postgres runtime migrator takes an advisory lock by default so two app boots do not apply the same file concurrently. Newly applied files record SHA-256 checksums; if a checksummed migration is edited later, the runner fails before applying pending files. CLI-applied and older filename-only rows are skipped without checksum validation. Use { dryRun: true } to list pending migrations without applying them or changing the tracking table.

An engine client is required locally

apco db migrate shells out to psql for Postgres or mariadb for MariaDB, and the matching client must be installed on your machine. With an explicit --channel, it fetches that managed branch's credentials and does not silently use an unrelated shell URL. Without --channel, the compatibility behavior remains: an ambient DATABASE_URL wins, otherwise Production credentials are fetched. Use --dir <path> to point at a different migrations directory.

The Database Studio's Migrations card shows the same history read straight from _apco_migrations — useful for confirming what's actually landed on the cloud database without shelling out. Programmatically, GET /projects/:id/database/migrations?channel=dev returns { tableExists, migrations: [{ name, appliedAt }] }.

Query from the CLI

The same data plane behind the Database Studio is on the CLI, so you can inspect and query the cloud database without opening the dashboard or installing a local database client. These commands go through the API and run as your tenant role.

bash
apco db query "select id, text from todos order by id"   # run SQL
apco db query --channel preview "delete from test_data" # Preview only
apco db schema                    # tables (columns/indexes/FKs) + views
apco db tables                    # just the table list
apco db tables todos --count      # one table's columns + an exact row count
apco db usage                     # size vs limit, connections, read-only flag
apco db migrations                # applied _apco_migrations rows

apco db query takes SQL as a positional argument, from --file <path>, or on stdin (in that precedence — supplying more than one, or none, is a usage error):

bash
apco db query --file report.sql
echo "select count(*) from todos" | apco db query

Results are printed as an aligned table in human mode and returned with the selected channel plus { columns, rows, rowCount, truncated, durationMs, rolledBack } in --json (rows are positional arrays aligned to columns).

Row caps

The server returns at most 500 rows — when it truncates, truncated is true and the CLI warns results truncated to 500 rows by the server. In human mode the printed table additionally shows at most 100 rows with a … N more rows not shown (use --json) note. Add a limit/where to narrow big result sets.

Dry-run a write

On Postgres, --dry-run runs the statement inside a transaction that is always rolled back—the response reflects what would happen without persisting anything. rolledBack comes back true:

bash
apco db query --dry-run "update todos set done = true where id = 1"

MariaDB rejects --dry-run with DRY_RUN_UNAVAILABLE: its DDL and some other statements can commit implicitly, so wrapping arbitrary SQL would promise safety the engine cannot guarantee. Rehearse destructive MariaDB work against the isolated Preview or Dev branch instead. apco db query needs the database:query or projects:write scope; the read-only introspection commands (schema, tables, usage, migrations) need projects:read. All default to Production; pass --channel preview or --channel dev to select another branch.

Backups

Manage backups from the CLI:

bash
apco db backup create --channel preview   # back up Preview; polls until complete
apco db backup create --channel dev --no-wait
apco db backup list --channel production
apco db backup list --all-channels        # include source channel on every row
apco db backup download <backupId> # stream the .sql.gz to a local file
apco db backup download <backupId> --output ./backup.sql.gz
apco db backup delete <backupId> --yes   # destructive; removes the platform's copy

The worker runs pg_dump or mariadb-dump as the selected branch's tenant role (plain SQL, gzip-compressed) and stores it as a single .sql.gz file. Backup metadata records the source channel. apco db backup create polls the backup list until it is complete (add --no-wait to return right after the request is queued). download writes the file atomically and never prints its contents; without --output it uses the server's suggested filename in the current directory. You can also use the Database Studio's Backups card or the raw API:

MariaDB backups intentionally cover table/view schema and table data only; stored routines, events, and triggers are outside the managed Studio scope and must be recreated from application migrations.

bash
curl -X POST "https://apco.space/api/v1/projects/<id>/database/backups?channel=preview" -H "x-api-key: $APCO_TOKEN"
curl "https://apco.space/api/v1/projects/<id>/database/backups?allChannels=true" -H "x-api-key: $APCO_TOKEN"
curl https://apco.space/api/v1/projects/<id>/database/backups/<backupId>/download \
  -H "x-api-key: $APCO_TOKEN" -o backup.sql.gz
  • One at a time. Only one backup may be pending/running per project across all channels; requesting another while one is in flight returns BACKUP_IN_PROGRESS.
  • Capped by your plan. Completed backups across all channels are capped at your plan's maxBackups (0 disables backups). Delete an old one before requesting a new one once you're at the cap — otherwise QUOTA_EXCEEDED.
  • Retention. Backups are kept until you delete them — there's no automatic expiry of completed backups. A backup stuck in pending/running for more than 2 hours (e.g. a killed worker) is automatically marked failed.
  • Not plan-gated to read. Listing, downloading, and deleting your existing backups works even on a downgraded plan — only requesting a new one is gated.

No server-side restore endpoint

There is no restore button or platform API — restore runs client-side via apco db restore (below), which streams a backup into your database from your machine. Deleting a backup only removes the platform's copy (the file is swept by a later housekeeping pass) and does not touch your live database.

Restore a backup

apco db restore restores a backup id or a local .sql/.sql.gz file into the selected target channel. A platform backup keeps its own source channel metadata, so restoring Preview into Production is possible only as an explicit destructive action whose confirmation names both channels.

Restoring overwrites your database

apco db restore runs the dump against the selected live channel database and overwrites its existing data — there is no undo. It requires --yes in --json/non-interactive mode. Promotion never performs a restore or moves data automatically.

bash
apco db restore b1 --channel production --yes
apco db restore ./backup.sql.gz --channel dev --yes
apco db restore ./backup.sql --channel preview --yes

The argument is treated as a local file when it names an existing file, otherwise as a backup id to download first (to a temp file, cleaned up afterward). .sql.gz files are gunzipped automatically (the CLI sniffs the gzip magic bytes), and the dump is streamed into psql -v ON_ERROR_STOP=1 or the mariadb client selected from the target URL. The connection string is fetched only for the child process and is never printed; MariaDB passwords are passed through MYSQL_PWD, not command arguments.

An engine client is required locally

Like apco db migrate, restore requires psql for Postgres or mariadb for MariaDB. A missing binary or failed run returns DATABASE_NOT_READY with the safe error tail and a redacted connection string.

If you'd rather run it manually—or restore into a database APCO doesn't manage—the equivalent commands are:

bash
gunzip -c backup.sql.gz | psql "$DATABASE_URL"
# MariaDB:
gunzip -c backup.sql.gz | mariadb --host <host> --user <user> --password --database <database>

Credential rotation

Rotate from the CLI:

bash
apco db rotate-credentials --channel preview --yes

No secret is printed — the new password stays server-side; fetch a fresh DATABASE_URL afterward with apco dev --db. You can also rotate from the Database Studio's Connection card (Rotate credentials), or the API:

bash
curl -X POST "https://apco.space/api/v1/projects/<id>/database/rotate-credentials?channel=preview" \
  -H "x-api-key: $APCO_TOKEN"

Generates a brand-new password for the selected channel's tenant role immediately. Use this after a suspected credential leak — the SQL console and dev credentials (apco dev --db) keep working afterward, since those always fetch the current stored credentials.

That channel's running deployment loses access immediately

The old password stops working the moment rotation completes — there is no grace period. Redeploy/restart Production or Dev, or deploy a new Preview, to pick up the selected branch's new DATABASE_URL. Other channels are unchanged.

Local development

apco dev --db ensures the Dev database and writes its connection URL into your local .env.local, so your local server and apco dev --sync share Dev data — see Hybrid dev.

Channels are isolated

Production, Preview, and Dev are separate physical databases with separate roles. A new branch starts empty: run your migrations for Dev before local development. Destructive local queries do not touch Production.

Disabling (destructive)

bash
apco db disable --channel preview --yes

Drops the selected channel database and all its data — there is no undo. It does not stop a running channel deployment, which will lose database access until you recreate the branch and redeploy/restart. Completed backups remain available. The --yes flag is required in --json/non-interactive mode. Disabling is deliberately not plan-gated, so you can clean up even after downgrading.

Quick reference

CommandWhat it does
apco db enable [--engine postgres|mariadb] [--no-wait]Provision Production; first branch selects the immutable engine
apco db branch <production|preview|dev> [--engine postgres|mariadb] [--no-wait]Provision one empty channel database
apco db status [--channel <channel>]Status, size vs limit, connection limit
apco db query [sql] [--file <path>] [--dry-run]Run SQL as the tenant role (≤500 rows; dry run is Postgres-only)
apco db schemaTables (columns/indexes/FKs) + views
apco db tables [table] [--count]List tables, or one table's columns (+ exact count)
apco db usageSize vs limit, connections, read-only flag, size history
apco db migrationsApplied _apco_migrations rows
apco db migrate [--channel <channel>] [--dir <path>]Apply .apco/migrations/*.sql (idempotent)
apco db disable [--channel <channel>] --yesDrop one channel database (destructive)
apco db backup list [--channel <channel>|--all-channels]List backups with source channels
apco db backup create [--channel <channel>] [--no-wait]Request an engine-native SQL backup; polls until complete
apco db backup download <backupId> [--output <path>]Stream a completed backup to a local file
apco db backup delete <backupId> --yesDelete the platform's copy of a backup (destructive)
apco db restore <backupId|file> [--channel <target>] --yesRestore into one target branch (destructive; needs the matching client)
apco db rotate-credentials [--channel <channel>] --yesRotate one branch password

All accept --project <slug|id>. Branch-bound commands default to Production unless noted otherwise.

APCO Cloud — ship apps with one command.