---
title: Database
description: Migrations, connection pooling, row-level security, backups, and what to restore when.
---

One PostgreSQL database, shared by the control plane, the gateway and the
worker. PostgreSQL 14 or newer.

## Migrations

Nine migrations, applied in order, forward-only.

| | Covers |
| --- | --- |
| `0001` | Tenancy and people — organizations, workspaces, environments, members, roles |
| `0002` | Agents — identities, keys, generations, passports, capability grants |
| `0003` | Tools and credentials — the registry, sealed material, leases |
| `0004` | Policy and delegation — policies, versions, test cases, bindings, delegations |
| `0005` | Risk, approval and trust — signals, events, approvals, evidence |
| `0006` | Actions and audit — action records, stage results, the hash-chained trail |
| `0007` | Security and protocols — incidents, emergency controls, MCP, A2A, registry |
| `0008` | Platform — webhooks, API keys, usage, plans, subscriptions, SSO, SCIM |
| `0009` | Row-level security — the policies, applied when enabled |

```bash
npm run migrate           # apply
npm run migrate:check     # apply to a throwaway database and verify it comes out clean
```

`migrate:check` is the CI gate. It proves every migration applies to an empty
database — which is the thing that breaks when someone edits an already-applied
migration instead of adding a new one.

:::warning Never edit an applied migration
Add a new one. An edited migration has already run on every deployed
environment, so the edit silently applies only to new ones and your
environments diverge with nothing to indicate it.
:::

:::note A real bug in the migration recording
node-postgres returns an **array** of results for multi-statement SQL. The
driver was reading `.rows` off the array, got `undefined`, and threw — *after*
the DDL had already committed. The schema was applied but not recorded, so the
next run tried to apply it again and failed on an existing table.

The driver now normalises multi-statement results. If you ever see a migration
that "failed" but whose tables exist, that class of bug is what it looks like.
:::

## Pooling

```ini
DATABASE_POOL_MAX=20
DATABASE_STATEMENT_TIMEOUT_MS=15000
```

`DATABASE_POOL_MAX` is **per process instance**. Three control plane instances,
two gateway instances and one worker at 20 each is 120 connections. Size
PostgreSQL's `max_connections` for the total, and consider PgBouncer in
transaction mode once you are past a handful of instances.

`DATABASE_STATEMENT_TIMEOUT_MS` bounds a single statement. The gateway is on the
latency path for every protected action, so a query that runs away must be cut
off rather than holding a connection.

## Row-level security

```ini
DATABASE_ENABLE_RLS=true
```

Turns on PostgreSQL row-level security as a **second, independent** isolation
layer beneath the application-level tenant scoping.

:::security Enable it in production
The application layer already requires a tenant scope on every query and refuses
without one. RLS exists so that a bug in that layer — a query that forgets its
scope, a new repository method that does not inherit the guard — does not become
a cross-tenant data leak.

Two independent layers, where either alone would be sufficient. That is the
point of defence in depth: you do not have to be confident that the first layer
is perfect.
:::

See [Tenant isolation](/secure/tenant-isolation).

## The embedded driver

Veefore ships PGlite, an embedded PostgreSQL, selected by `PGLITE_DATA_DIR` when
`DATABASE_URL` is absent.

**Use it for:** isolated unit and integration tests, where a private
per-process database is exactly what you want.

**Do not use it for:** running the stack.

:::danger PGlite is per-process
Each process gets its own private copy. The control plane, the gateway, the
worker and any seed script would see none of each other's writes.

The symptom is not an error. It is an agent you just created not existing, a
policy you just activated not applying, and a login that works in one process
and fails in another. Set `DATABASE_URL`.
:::

Related: `close()` on the embedded driver is now terminal. It previously booted
a fresh empty database on the next query, which made the gateway's readiness
check stay green with no data behind it.

## Backups

Standard PostgreSQL practice, with one Veefore-specific consideration.

```bash
pg_dump --format=custom --no-owner --no-privileges veefore > veefore.dump
```

:::danger A database backup is not a complete backup
Credential material in the dump is **sealed with
`VEEFORE_DATA_ENCRYPTION_KEY`**, which is not in the database. Restore the dump
without that key and every credential is unreadable.

The audit chain has the same property with `VEEFORE_AUDIT_CHAIN_SECRET`:
restore without it and you can read the events but you cannot verify integrity.

Back up the four keys alongside the database, in your secret manager, and test
a restore that uses both. A backup you have not restored is a hypothesis.
:::

Point-in-time recovery is worth configuring. The audit trail is append-only in
practice, so PITR gives you a consistent view of decisions as of any moment —
which is the thing an investigation needs.

## Restoring

1. Restore the database.
2. Ensure the four keys match the ones in use when the backup was taken.
3. `npm run migrate` — bringing a restored older snapshot up to the current schema.
4. `veefore audit verify` — confirms the chain survived the round trip.
5. Start the processes.

Step 4 is the one people skip and it is the one that tells you whether the
restore is sound.

## Retention and growth

The two tables that grow without bound are `actions` and `audit_events`, roughly
proportional to protected action volume.

Audit retention is configurable, and changing it requires
`audit:configure_retention` — step-up gated, writing a `CRITICAL` audit event.
Shortening retention destroys evidence, so a request to "clean up old audit
data" leaves a mark explaining who asked and why.

Export before you shorten:

```bash
veefore audit export --format csv \
  --reason "Pre-retention-change archive" --out archive.csv
```

The export is watermarked and hashed, so the archive is verifiable later.

## Health

`/health/ready` on both planes checks database reachability. A readiness probe
pointed at it will pull an instance out of rotation when its database connection
is gone, rather than letting it serve errors.

## Related

:::cards

:::card Data model | /architecture/data-model
What the tables are and how they relate.
:::

:::card Tenant isolation | /secure/tenant-isolation
Both isolation layers in detail.
:::

:::card Dependency outage | /operate/runbook-dependency-outage
What happens when the database is unreachable.
:::

:::card Configuration | /operate/configuration
Database settings and the four keys.
:::

:::
