Postgres¶
dataland-postgres is the single on-host relational store, shared by two
services in the dataland database (isolated by table, no foreign keys crossing
between them — independent identity systems by design):
dataland-agent— conversations, messages, tickets, users, runs.auth(auth_server.py, containerdataland-auth) — theauth_server_usersaccounts table.
auth reaches this instance in-network via AUTH_DATABASE_URL (falling back to
DATABASE_URL); there is no separate managed database.
| Container | dataland-postgres |
| Image | postgres:16-alpine |
| Database | dataland |
| Host ports | 127.0.0.1:5432 (loopback) + ${POSTGRES_PUBLIC_BIND:-100.124.170.43}:5432 (tailnet) — never 0.0.0.0 |
| Service-to-service | dataland-postgres:5432 on the dataland-network Docker bridge |
| Memory / CPU | mem_limit: 1g, mem_reservation: 256m, cpus: 1.0 |
| Persistence | named volume postgres-data → /var/lib/postgresql/data |
| Healthcheck | pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} (10s interval, 5 retries) |
Service definition (compose)¶
The container is defined in dataland-infrastructure/compose.yml. The tuning
flags are passed explicitly because the postgres:16-alpine defaults assume a
~128 MB box; on the 1 GB container most of the RAM would otherwise go unused and
the planner stats stay stale.
postgres:
image: postgres:16-alpine
container_name: dataland-postgres
mem_limit: 1g
mem_reservation: 256m
cpus: 1.0
command:
- "postgres"
- "-c"
- "shared_buffers=256MB" # (1)!
- "-c"
- "work_mem=8MB" # (2)!
- "-c"
- "effective_cache_size=512MB" # (3)!
- "-c"
- "max_connections=100" # (4)!
environment:
POSTGRES_USER: ${POSTGRES_USER:-dataland} # (5)!
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dataland}
POSTGRES_DB: ${POSTGRES_DB:-dataland}
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432" # (6)!
- "${POSTGRES_PUBLIC_BIND:-100.124.170.43}:${POSTGRES_PORT:-5432}:5432" # (7)!
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dataland} -d ${POSTGRES_DB:-dataland}"] # (8)!
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
- Passed explicitly because the
postgres:16-alpinedefault assumes a ~128 MB box. Sized for the 1 GB container so the page cache and planner stats actually use the available RAM. - Per-sort/hash working memory. Multiplied across concurrent operations, so kept modest against
max_connections=100to avoid OOM on the 1 GB box. - Planner hint for how much memory the OS + Postgres can use for caching. It does not reserve RAM; it just steers plan choice toward index scans.
- Hard ceiling on backend connections. The agent's worst-case pool demand (
workers × (pool_size + max_overflow)= 120) already exceeds this — see the pool-budget tip below. Raise this andshared_bufferstogether if you add workers. - Defaults to the documented
dataland:datalandlocal-dev role. A production volume must be initialized with rotatedPOSTGRES_USER/POSTGRES_PASSWORD— see Rotated, non-default credentials. - Loopback binding for local host tooling and the SSH-tunnel workflow. The
${POSTGRES_PORT:-5432}default keeps both the published and container ports at 5432. - Tailnet interface IP only (default
100.124.170.43), never0.0.0.0. This is the trust boundary: published on exactly two host IPs, both non-public. - Liveness probe run as the configured role against the configured DB; combined with the 10s
intervaland 5retriesit gives Compose a ~50s window before marking the container unhealthy.
Host binding is the trust boundary
Postgres is stateful and must be reachable for tooling and tailnet peers, but 0.0.0.0 once exposed 5432 on the public Spectrum IP. It is now bound only to 127.0.0.1 (local host tooling + the SSH-tunnel workflow) and POSTGRES_PUBLIC_BIND (a tailnet interface IP, default 100.124.170.43). Service-to-service traffic goes over the Docker network and is unaffected by host port binding. See Ports.
Connection strings¶
Each consumer has its own env var. The agent uses the async SQLAlchemy driver (asyncpg); the auth server uses raw asyncpg; Alembic rewrites to a sync driver at runtime.
| Consumer | Env var | Default / value | Driver |
|---|---|---|---|
dataland-agent |
DATABASE_URL |
postgresql+asyncpg://dataland:dataland@dataland-postgres:5432/dataland |
SQLAlchemy async (asyncpg) |
auth (dataland-auth) |
AUTH_DATABASE_URL (falls back to DATABASE_URL, then a local-dev default) |
postgresql://dataland:***@dataland-postgres:5432/dataland |
raw asyncpg pool |
| Alembic migrations | DATABASE_URL (else sqlalchemy.url in alembic.ini) |
same as the agent | sync (psycopg2) |
The agent's DATABASE_URL carries the +asyncpg suffix; the auth server's AUTH_DATABASE_URL is a plain postgresql:// URL because auth_server.py normalizes any postgresql+asyncpg:// it receives down to postgresql:// before handing it to asyncpg.create_pool (_normalize_postgres_dsn in auth_server.py).
Agent engine (app/db/session.py)¶
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_pre_ping=True, # (1)!
pool_size=20, # (2)!
max_overflow=40, # (3)!
)
async_session = async_sessionmaker(engine, expire_on_commit=False) # (4)!
- Validates a pooled connection with a cheap
SELECT 1before use, so a Postgres restart surfaces as a transparent reconnect instead of stale-connection errors on the next request. - Steady-state connections per worker. Postgres only — the SQLite path used in tests skips pool sizing entirely.
- Burst capacity above
pool_size, so each worker can reach20 + 40 = 60connections. Combined with the default 2 uvicorn workers, the worst case is 120 — abovemax_connections=100(see the tip below). expire_on_commit=Falsekeeps attributes loaded on committed objects, so the async handlers can still read them after the session commits without triggering a fresh lazy load.
pool_pre_ping=True validates a pooled connection before use (cheap SELECT 1), so a Postgres restart doesn't surface as stale-connection errors on the next request. Pool sizing (pool_size=20, max_overflow=40 → up to 60 connections per worker) is only applied for Postgres URLs; the SQLite path used in tests skips it.
Pool budget vs max_connections
The agent runs uvicorn --workers ${UVICORN_WORKERS:-2}. Each worker owns its own pool, so the worst-case agent connection count is workers × (pool_size + max_overflow) = 2 × 60 = 120 — which already exceeds max_connections=100. In practice the overflow is rarely touched, but if you raise UVICORN_WORKERS for the agent, raise Postgres max_connections (and shared_buffers) to match, or add PgBouncer.
Rotated, non-default credentials¶
The dataland:dataland user/password baked into every compose default and .env.example is a local-dev convenience only. A production deploy that inherits it is talking to publicly documented credentials.
This is enforced at three points:
flowchart TD
A[.env on the VDS] --> B{deploy.sh pre-build guard}
B -- "default creds / placeholder" --> X[ABORT before rebuild]
B -- ok --> C[docker compose up --build]
C --> D{agent lifespan<br/>assert_boot_required_env<br/>APP_ENV=production}
D -- "dataland:dataland or localhost" --> Y[RuntimeError → worker exits]
D -- ok --> E[init_db → serve]
-
Compose render-time — the agent's own standalone
docker-compose.ymluses the:?form so Compose errors before rendering ifDATABASE_URLis unset: -
The
:?form makes Compose hard-fail at render time with this message ifDATABASE_URLis unset or empty — unlike the:-default form, it never silently substitutes a value. This is the first of the three credential guards.
(The infrastructure compose.yml keeps a :- default for dev ergonomics; the boot guard below is what protects production there.)
-
Pre-deploy guard —
deploy.shruns the real guard from the currentdataland/agent:latestimage against the new env before rebuilding: -
Runs the exact boot guard from the last-built image against the new env, so default/placeholder creds abort the deploy before the rebuild instead of crash-looping the freshly built container. Skipped on the first deploy when no
:latestimage exists yet.
If it fails, the deploy aborts before the rebuild — preventing the crash-loop outage that motivated the check. Skipped on the very first deploy when no :latest image exists yet.
- Boot-time guard (
app/runtime.py) — whenAPP_ENV=production,assert_boot_required_env()raisesRuntimeErrorfrom the lifespan (which exits the worker; Docker surfaces it as the container exit reason) ifDATABASE_URL: - is empty,
- still contains the literal
dataland:datalandcredentials (DEFAULT_DATABASE_CREDS), or - points at
localhost/127.0.0.1(a prod deploy must use thedataland-postgresnetwork hostname or a managed-DB URL).
Development and CI keep the warn-only path, so a feature branch with default creds still boots.
What to rotate
Rotate POSTGRES_USER / POSTGRES_PASSWORD (initializes the role on a fresh postgres-data volume) and the credentials embedded in the agent's DATABASE_URL (and, if it doesn't inherit DATABASE_URL, AUTH_DATABASE_URL). They must agree. Generate with e.g. openssl rand -hex 32. Changing POSTGRES_PASSWORD on an existing volume does not re-key the role — ALTER ROLE ... PASSWORD inside the running container, or recreate the volume from a backup.
Schema — agent tables¶
Defined as SQLAlchemy models in app/models.py (Base.metadata). Every created_at / updated_at is timestamptz.
erDiagram
users ||--o{ conversations : has
users ||--o{ tickets : has
users ||--o{ runs : has
conversations ||--o{ messages : contains
conversations ||--o{ runs : groups
messages ||--o{ message_images : has
messages ||--o{ message_sources : has
messages ||--o{ message_suggestions : has
conversations }o--|| tickets : "id == ticket_id (museum)"
users¶
The agent's mobile-visitor identity. users.id IS the JWT sub claim — there is no separate external_id column. The first authenticated request for a given sub writes the row; later requests refresh profile fields that changed in the token (app/db/user_repo.py::get_or_create_user).
| Column | Type | Notes |
|---|---|---|
id |
text PK | the JWT sub |
email |
text, null | |
full_name |
text, null | mirror of the museum-wide JWT claim |
location |
text, null | |
profile_photo_url |
text, null | |
joined_date |
timestamptz, null | |
access_permissions |
text, null | JSON-encoded list |
stripe_customer_id |
text, null | |
created_at |
timestamptz | defaults to now() (app-side _utcnow) |
conversations¶
| Column | Type | Notes |
|---|---|---|
id |
text PK | UUID4, except museum conversations where id == ticket_id |
user_id |
text FK → users.id, indexed |
|
title |
text, null | |
mode |
text, null | "general" or "museum" |
messages_json |
text, null | denormalized message blob |
created_at / updated_at |
timestamptz | updated_at has onupdate |
tickets¶
One museum ticket = one user = one museum-mode conversation. The model is intentionally minimal: the conversation for a ticket is always the conversations row with id = ticket_id, so the mobile client uses ticket_id directly as conversation_id with no lookup. Registration is idempotent (app/db/ticket_repo.py::register_ticket).
| Column | Type | Notes |
|---|---|---|
id |
text PK | the ticket_id |
user_id |
text FK → users.id, indexed |
|
first_seen |
timestamptz | defaults to now() |
Ticket registration
There is no /register or /current endpoint — the first /v1/chat/museum message registers the ticket, and conversation_id == ticket_id. See Agent.
runs¶
One agent invocation, made first-class instead of derived from messages.run_id. Telemetry fields are nullable so the app can fill them opportunistically.
| Column | Type | Notes |
|---|---|---|
id |
text PK | the run id |
conversation_id |
text FK → conversations.id, indexed |
|
user_id |
text FK → users.id, indexed |
|
started_at / finished_at |
timestamptz | |
model |
text, null | e.g. gemini-3.5-flash |
prompt_tokens / completion_tokens |
int, null | |
cost_usd |
float, null | |
status |
text, null | success / error / cancelled |
error |
text, null |
messages.run_id continues to point at this id without a hard FK so historical rows that predate the table aren't blocked.
messages¶
| Column | Type | Notes |
|---|---|---|
id |
text PK | |
conversation_id |
text FK → conversations.id, indexed |
|
seq |
int, NOT NULL | per-conversation ordering (unique with conversation_id) |
run_id |
text, null | soft link to runs.id |
role |
text, indexed | user / assistant / system / tool |
tool, tool_call_id |
text, null | |
text |
text, null | |
feedback |
text, null | like / dislike |
created_at |
timestamptz, null |
Message children¶
Cascade-deleted with their parent message (cascade="all, delete-orphan"):
message_images—title,artist,image_path,score,type(defaultartwork). Populated bysearch_artwork_images.message_sources—title,uri,confidence,type(defaultdocument). RAG citations.message_suggestions—text,sequence. Post-stream follow-up suggestions, restored on conversation reload.
Each uses a BIGSERIAL-style integer PK (autoincrement) and an indexed message_id FK.
Schema — auth server table¶
auth_server.py does not use SQLAlchemy or Alembic. It creates and patches its own table at startup via raw asyncpg DDL in _init_db(), in the same on-host dataland database. This table is independent of the agent's users table.
auth_server_users¶
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL PK | |
sub |
text, UNIQUE | UUID minted on signup; the JWT sub |
email |
text, UNIQUE, NOT NULL | lowercased on signup/login |
hash |
text, NOT NULL | Argon2id |
salt |
text, NOT NULL | "argon2" sentinel for new rows; legacy rows keep their sha256 salt |
full_name |
text, null | |
location |
text, NOT NULL DEFAULT '' |
|
profile_photo_url |
text, null | |
stripe_customer_id |
text, null | |
created_at |
timestamptz, NOT NULL DEFAULT now() |
_init_db() is idempotent: it CREATE TABLE IF NOT EXISTS, then ADD COLUMN any missing columns (checked against information_schema.columns), then backfills a UUID sub into any rows where it is NULL.
Password hashing — Argon2id
New rows are hashed with Argon2id (argon2-cffi defaults: time_cost=3, memory_cost=64 MiB, parallelism=4, ~70–90 ms on the VDS). Legacy sha256(salt + password) rows still verify via the 64-hex-char path and are opportunistically re-hashed to Argon2id on their next successful login, so the table migrates itself as users return.
Migrations (Alembic)¶
Agent schema evolution lives in migrations/versions/ (config in alembic.ini, environment in migrations/env.py). The runs / tickets / profile-field DDL above all arrived as numbered revisions.
0001_baseline # (1)!
0002_utm_active_uniq
0003_messages_conv_seq_uniq # (2)!
0004_users_email_lower_uniq
0005_drop_messages_event
0006_messages_created_at_tz
0007_lift_timestamps_to_tz # (3)!
0008_tickets_table # (4)!
0009_runs_table # (5)!
0010_user_profile_fields # (6)!
- Empty marker — the pre-Alembic schema came from
create_all. Adopting Alembic on an existing DB meansalembic stamp 0001_baselinefirst, so revisions0002+apply cleanly. - Adds
UNIQUE(conversation_id, seq)— the constraint that enforces per-conversation message ordering described in themessagestable above. - Lifts every
created_at/updated_attotimestamptz. After this all agent timestamps are tz-aware. - Introduces the
ticketstable. Its richerexternal_id/last_seen/visit_countcolumns were later superseded by the slim model above. - Introduces the first-class
runstelemetry table. - Adds the
full_name/location/profile_photo_url/joined_date/access_permissions/stripe_customer_idcolumns tousers.
See Migrations for authoring/running migrations and the vector re-ingest story.
How the URL is resolved¶
migrations/env.py::_resolve_url() reads DATABASE_URL (falling back to sqlalchemy.url in alembic.ini) and rewrites the async driver to a sync one, because Alembic runs synchronously:
postgresql+asyncpg://…→postgresql+psycopg2://…sqlite+aiosqlite:///…→sqlite:///…- bare
postgresql://…→postgresql+psycopg2://…
So the same DATABASE_URL the app uses works for migrations — no separate migration DSN.
init_db vs migrations¶
app/db/session.py::init_db() runs at agent boot (from the lifespan, after the production env guard). It is not a migration runner:
- It instruments the SQLAlchemy engine for observability.
- It takes a Postgres advisory lock (
pg_advisory_xact_lock(8675309)) so two boot-racing workers can't both callcreate_all(). - It runs
Base.metadata.create_all— which creates missing tables for a fresh DB but never alters existing ones.
On an existing DB, create_all is a no-op and Alembic owns all forward changes.
Apply migrations before bringing the agent up
Operators run alembic upgrade head (with DATABASE_URL set) before the agent serves traffic. For a brand-new environment: let init_db build the schema from the current models, then alembic stamp head so Alembic agrees it's current. For an existing DB adopting Alembic: alembic stamp 0001_baseline, then alembic upgrade head.
scripts/dl agent /app/.venv/bin/alembic current # (1)!
scripts/dl agent /app/.venv/bin/alembic upgrade head # (2)!
- Run from inside the agent container, where
DATABASE_URLis already set. Prints the revision Alembic believes is applied — use it to confirm a DB's state before upgrading. - Applies every pending revision up to the latest. Operators run this before the agent serves traffic;
init_db'screate_allnever alters existing tables, so Alembic owns all forward changes.
Reaching it¶
scripts/dl postgres psql -U dataland -d dataland # (1)!
ssh -L 5432:127.0.0.1:5432 ege@100.124.170.43 # (2)!
psql -h 127.0.0.1 -p 5432 -U dataland -d dataland
psql -h 100.124.170.43 -p 5432 -U dataland -d dataland # (3)!
scripts/dl postgres psql -U dataland -d dataland -c '
SELECT relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC
LIMIT 10
' # (4)!
- On the host — exec straight into the container's
psql, bypassing host port bindings entirely. - From a workstation — tunnels the loopback binding (
127.0.0.1:5432) over SSH, then connectpsqlto your now-local127.0.0.1:5432. Use this when you are not on the tailnet. - From a tailnet peer — connect directly to the
POSTGRES_PUBLIC_BINDtailnet IP (default100.124.170.43), no tunnel needed since that interface is bound. - Top tables by live row count — reads
pg_stat_user_tablesfor a quick size/activity snapshot. Counts are planner estimates (n_live_tup), not an exactCOUNT(*).
Observability¶
The agent's SQLAlchemy engine is instrumented (init_db → instrument_sqlalchemy_engine), so query spans appear in the agent's Logfire traces. The self-hosted Postgres metrics exporter was part of the metrics stack that is turned off (2026-07); there is no Prometheus scrape today. See Observability.
Volumes + backup¶
- Holds every conversation, message, ticket, run, the agent's
userstable, and the auth server'sauth_server_userstable (all in thedatalanddatabase). RotatingPOSTGRES_USER/POSTGRES_PASSWORDon a fresh role requires recreating this volume — back up first (see the danger admonition below).
Back up before any destructive op
postgres-data holds every conversation, message, ticket, run, and both the agent's users and the auth server's auth_server_users tables. The DR procedure lives in dataland-infrastructure/reports/backup-restore.md. Run it before any reset-stack.sh or before recreating the postgres-data volume to rotate POSTGRES_USER / POSTGRES_PASSWORD.
See also¶
- Agent — primary writer of the conversation/ticket/run schema.
- Auth —
auth_server.py, theauth_server_usersstore, JWKS, and the CMS-key mirror. - Ports — host-binding policy for
5432and the other datastores. - Migrations — Alembic chain, data backfills, vector re-ingest.