Postgres Deployment Strategies¶
VibeWarden uses PostgreSQL for two purposes:
- Ory Kratos — stores user identities, sessions, recovery tokens, and
verification codes. This is always required when
auth.modeiskratos. - Audit logging — the
database.external_urlfield connects VibeWarden itself to a Postgres instance for structured audit event persistence (optional; structured logs are always written to stdout regardless).
Important: VibeWarden's Postgres instance is for VibeWarden and its sub-components (Kratos, audit log). Your wrapped application should connect to its own, independent database. Never share VibeWarden's database with your application — doing so couples their schemas and upgrade paths.
Strategy 1 — Local Docker Compose (dev and cost-conscious single-instance)¶
The default Docker Compose setup starts a local Postgres container alongside VibeWarden and Kratos. This is the recommended starting point for:
- Local development
- Single-server deployments where a managed database is not needed
- Cost-sensitive setups (Hetzner VPS + local Postgres is the cheapest viable stack)
How it works: the docker-compose.yml generated by vibew init includes
a postgres service. Kratos and VibeWarden connect to it using the container
service name (postgres) as the hostname on the internal Docker network.
vibewarden.yaml (database section omitted — uses local Postgres via Docker Compose):
# No database section needed.
# VibeWarden picks up the local Postgres automatically when running inside
# the generated Docker Compose stack.
docker-compose.yml (relevant portion):
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
kratos-migrate:
image: oryd/kratos:v1.3.1
environment:
DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
command: migrate sql -e --yes
depends_on:
postgres:
condition: service_healthy
kratos:
image: oryd/kratos:v1.3.1
environment:
DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
depends_on:
kratos-migrate:
condition: service_completed_successfully
volumes:
postgres_data:
.env:
Backup: see Production Deployment Guide — Postgres backup.
Limitations: - Postgres data lives on the same machine as your app. A disk failure loses both. - Not suitable for active/active multi-instance deployments (see Strategy 3). - No automated backups unless you set up a cron job yourself.
Strategy 2 — External Managed Postgres (single-instance production)¶
For production deployments, replace the local Postgres container with a managed database service. This provides:
- Automated daily backups with point-in-time recovery (PITR)
- Failover / high availability
- Offloaded maintenance (upgrades, vacuuming, monitoring)
- TLS enforced by the provider
vibewarden.yaml:
database:
external_url: "postgres://vibewarden:${DB_PASSWORD}@db.example.com:5432/vibewarden?sslmode=require"
tls_mode: require
connect_timeout: "10s"
pool:
max_conns: 10
min_conns: 2
When database.external_url is set, the generated Docker Compose omits the
local postgres container and uses this URL as the Kratos DSN.
docker-compose.yml (relevant difference from Strategy 1):
services:
# No local postgres service.
kratos-migrate:
image: oryd/kratos:v1.3.1
environment:
DSN: ${VIBEWARDEN_DATABASE_EXTERNAL_URL}
command: migrate sql -e --yes
kratos:
image: oryd/kratos:v1.3.1
environment:
DSN: ${VIBEWARDEN_DATABASE_EXTERNAL_URL}
Pass the URL securely via an environment variable:
# .env
VIBEWARDEN_DATABASE_EXTERNAL_URL=postgres://vibewarden:strongpassword@db.example.com:5432/vibewarden?sslmode=require
AWS RDS (PostgreSQL)¶
Create a PostgreSQL 17 instance in RDS. Recommended settings:
- Instance class:
db.t4g.micro(dev) /db.t4g.small(prod) - Storage: gp3, 20 GB minimum, auto-scaling enabled
- Multi-AZ: enabled for production
- Public access: disabled (connect from within the same VPC or via a bastion)
- Backup retention: 7 days minimum
- Parameter group: default (no custom parameters required)
After the instance is available, create a dedicated role:
CREATE ROLE vibewarden WITH LOGIN PASSWORD '<strong-password>';
CREATE DATABASE vibewarden OWNER vibewarden;
Connection string:
database:
external_url: "postgres://vibewarden:${DB_PASSWORD}@mydb.abc123.eu-west-1.rds.amazonaws.com:5432/vibewarden"
tls_mode: verify-full
connect_timeout: "10s"
pool:
max_conns: 10
min_conns: 2
RDS issues certificates from the AWS CA. Download the bundle and set
PGSSLROOTCERTif you need certificate verification against the CA bundle. For most setupstls_mode: require(no CA verification) is sufficient and avoids the certificate management overhead.
Supabase¶
Supabase provides a managed PostgreSQL instance with a built-in connection pooler (PgBouncer) and a REST API you do not need to use — VibeWarden connects directly to the underlying Postgres.
Locate your connection string in the Supabase dashboard: Project Settings → Database → Connection string → URI.
Use the direct connection (port 5432) for migrations. For the runtime connection, the pooler (port 6543, Transaction mode) is recommended when your instance will handle high request rates.
database:
# Direct connection (migrations, low-traffic):
external_url: "postgres://postgres.abcdefgh:${DB_PASSWORD}@aws-0-eu-central-1.pooler.supabase.com:5432/postgres"
tls_mode: require
connect_timeout: "10s"
pool:
max_conns: 5 # Supabase free tier limits concurrent connections
min_conns: 1
database:
# Pooler connection (high-traffic production):
external_url: "postgres://postgres.abcdefgh:${DB_PASSWORD}@aws-0-eu-central-1.pooler.supabase.com:6543/postgres"
tls_mode: require
connect_timeout: "10s"
pool:
max_conns: 10
min_conns: 2
On the Supabase free tier, Postgres pauses after 1 week of inactivity. Use a paid plan or set up a scheduled ping if you run a low-traffic production service.
Neon¶
Neon is a serverless PostgreSQL service with branching support. It is well suited for preview environments because each branch is an independent database.
Locate your connection string in the Neon dashboard: Project → Connection Details → Connection string.
database:
external_url: "postgres://vibewarden:${DB_PASSWORD}@ep-cool-rain-12345.eu-central-1.aws.neon.tech/neondb?sslmode=require"
tls_mode: require
connect_timeout: "15s" # Neon cold-start may add a few seconds on serverless plans
pool:
max_conns: 10
min_conns: 1 # allow pool to drain to 0 between requests on serverless
Neon's serverless compute may have a cold-start delay of 2–5 seconds after a period of inactivity. Set
connect_timeoutto at least"15s"to avoid spurious connection errors on low-traffic deployments.
Hetzner Managed Databases¶
Hetzner Managed Databases (PostgreSQL) is the recommended choice for EU-hosted VibeWarden deployments. It runs in the same data centre as your Hetzner VPS, keeping latency below 1 ms.
Create a PostgreSQL 16 or 17 cluster in the Hetzner Cloud Console. After creation, whitelist your server's private IP in the database firewall.
Copy the connection string from the Overview tab (service credentials).
database:
external_url: "postgres://vibewarden:<password>@<db-host>.hc-<region>.hetzner.cloud:5432/vibewarden"
tls_mode: require
connect_timeout: "10s"
pool:
max_conns: 10
min_conns: 2
Hetzner Managed Databases enforce TLS. Always use
tls_mode: requireor stronger.
Strategy 3 — Shared External Postgres (multi-instance)¶
When you run multiple VibeWarden instances protecting separate apps on separate servers — and you want a single Postgres cluster to back all of them — each instance must use the same external database with isolated schemas.
Use case: small business running three separate apps (blog, admin panel, API) on three servers, with a single managed Postgres backing all VibeWarden instances for cost and operational efficiency.
Schema isolation¶
Each VibeWarden instance (and its Kratos sidecar) needs its own Postgres database (not just a schema) to avoid cross-instance table name collisions in Kratos migrations.
Create one database per instance:
CREATE ROLE vw_blog WITH LOGIN PASSWORD '<pw-blog>';
CREATE ROLE vw_admin WITH LOGIN PASSWORD '<pw-admin>';
CREATE ROLE vw_api WITH LOGIN PASSWORD '<pw-api>';
CREATE DATABASE vw_blog OWNER vw_blog;
CREATE DATABASE vw_admin OWNER vw_admin;
CREATE DATABASE vw_api OWNER vw_api;
Per-instance vibewarden.yaml¶
Each instance points to its own database:
# blog.example.com
database:
external_url: "postgres://vw_blog:${DB_PASSWORD_BLOG}@shared-db.example.com:5432/vw_blog"
tls_mode: require
pool:
max_conns: 5
min_conns: 1
# admin.example.com
database:
external_url: "postgres://vw_admin:${DB_PASSWORD_ADMIN}@shared-db.example.com:5432/vw_admin"
tls_mode: require
pool:
max_conns: 5
min_conns: 1
Connection pool sizing for multi-instance¶
Postgres has a hard limit on max_connections (default 100 on most managed
services). With N VibeWarden instances each with a pool of max_conns, the
total connections consumed is:
Kratos typically uses 5–10 connections. For 10 instances:
If this exceeds your cluster's limit, either:
- Use a connection pooler (PgBouncer, Supabase pooler, or pgBouncer sidecar)
in Transaction mode.
- Reduce pool.max_conns per instance (e.g. 3).
- Upgrade the database cluster tier.
Migration coordination¶
Each instance runs its own Kratos migrations against its own database, so there is no cross-instance migration contention. Kratos migrations are idempotent — running them multiple times on the same database is safe.
Config reference¶
All database settings live under the database: key in vibewarden.yaml.
| Key | Type | Default | Description |
|---|---|---|---|
database.external_url |
string | "" |
Full postgres:// connection URL. When set, the generated Docker Compose omits the local Postgres container. |
database.tls_mode |
string | "require" |
PostgreSQL SSL mode: disable, require, verify-ca, or verify-full. Applied when sslmode is not already present in external_url. |
database.pool.max_conns |
int | 10 |
Maximum open connections in the pool. Applied as pool_max_conns query parameter. |
database.pool.min_conns |
int | 2 |
Minimum idle connections kept open. |
database.connect_timeout |
string | "10s" |
Maximum time to wait when establishing a new connection. Go duration string. Applied as the Postgres connect_timeout parameter (in seconds). |
TLS mode values¶
| Value | Behaviour |
|---|---|
disable |
No TLS. Plain-text connection. Use only on localhost or trusted private networks. |
require |
TLS required, but the server certificate is not verified. Protects against passive eavesdropping. |
verify-ca |
TLS required; certificate must be signed by a trusted CA. Use PGSSLROOTCERT to point at the CA bundle. |
verify-full |
TLS required; certificate CN/SAN must match the server hostname. Strongest option. |
Environment variable overrides¶
All database.* keys can be overridden with VIBEWARDEN_DATABASE_* environment
variables:
| Environment variable | Config key |
|---|---|
VIBEWARDEN_DATABASE_EXTERNAL_URL |
database.external_url |
VIBEWARDEN_DATABASE_TLS_MODE |
database.tls_mode |
VIBEWARDEN_DATABASE_POOL_MAX_CONNS |
database.pool.max_conns |
VIBEWARDEN_DATABASE_POOL_MIN_CONNS |
database.pool.min_conns |
VIBEWARDEN_DATABASE_CONNECT_TIMEOUT |
database.connect_timeout |
Migrating from local to external Postgres¶
Follow these steps to move from Strategy 1 (local Docker Compose Postgres) to Strategy 2 (external managed Postgres) with zero data loss.
Step 1 — Dump the local database¶
# On the server running the Docker Compose stack
docker exec myapp-postgres pg_dump \
-U "$POSTGRES_USER" "$POSTGRES_DB" \
> /tmp/vibewarden-$(date +%Y%m%d-%H%M%S).sql
Step 2 — Create the target database¶
Create the database and role on your managed service (see provider-specific instructions in Strategy 2 above).
Step 3 — Restore the dump¶
psql "postgres://vibewarden:<pw>@db.example.com:5432/vibewarden?sslmode=require" \
< /tmp/vibewarden-20260101-120000.sql
Step 4 — Update vibewarden.yaml¶
Add the database section:
database:
external_url: "postgres://vibewarden:${DB_PASSWORD}@db.example.com:5432/vibewarden"
tls_mode: require
connect_timeout: "10s"
pool:
max_conns: 10
min_conns: 2
Step 5 — Set the environment variable¶
# .env
VIBEWARDEN_DATABASE_EXTERNAL_URL=postgres://vibewarden:strongpassword@db.example.com:5432/vibewarden?sslmode=require
Step 6 — Stop the stack, remove local Postgres, restart¶
docker compose down
# Remove the postgres service from docker-compose.yml
# (or let vibew init regenerate it with the external_url set)
docker compose up -d
Verify Kratos starts and can reach the database:
docker compose logs kratos | grep "Starting"
curl https://myapp.example.com/_vibewarden/healthz
# {"status":"ok"}
Step 7 — Remove the old volume (after verifying everything works)¶
Backup and maintenance recommendations¶
Backups¶
| Strategy | Recommendation |
|---|---|
| Local Docker Compose | Daily pg_dump shipped to object storage (Hetzner Object Storage, S3-compatible). See production-deployment.md for a cron script. |
| Managed Postgres | Enable automated daily backups in the provider dashboard. Enable point-in-time recovery (PITR) for production. Set retention to at least 7 days. |
| Multi-instance | Back up each database independently. Managed services do this per-cluster automatically. |
Routine maintenance¶
Managed services (RDS, Supabase, Neon, Hetzner) handle VACUUM, ANALYZE, and minor-version security patches automatically. For local Docker Compose Postgres:
- Enable
pg_stat_statementsfor query monitoring. - Run
VACUUM ANALYZEweekly via a cron job. - Pin the Postgres image to a minor version (e.g.
postgres:17.2-alpine) and update deliberately. - Monitor disk usage:
SELECT pg_size_pretty(pg_database_size('vibewarden'));
Connection exhaustion¶
If you see sorry, too many clients already errors:
- Check current connections:
SELECT count(*) FROM pg_stat_activity; - Reduce
database.pool.max_connsinvibewarden.yaml. - Consider adding a PgBouncer sidecar in Transaction mode.
- Upgrade to a larger database tier if the connection limit is the bottleneck.
AI-agent notes¶
This section is machine-readable context for AI coding agents working on VibeWarden.
- The
database.external_urlfield invibewarden.yamlis the single control point for switching between local and external Postgres. - When
database.external_urlis non-empty, thevibew initcode generator omits thepostgresservice from the generateddocker-compose.ymland injects${VIBEWARDEN_DATABASE_EXTERNAL_URL}as the KratosDSN. DatabaseConfig.BuildDSN()ininternal/config/config.gomergesexternal_url,tls_mode,connect_timeout, andpool.max_connsinto a single DSN string, respecting any parameters already present in the URL.- TLS mode validation is in
internal/config/config.goValidate(); accepted values are exactlydisable,require,verify-ca,verify-full. - Default values:
tls_mode=require,pool.max_conns=10,pool.min_conns=2,connect_timeout=10s. - The
database.urlfield (withoutexternal_) is a legacy alias kept for backward compatibility. New configurations should useexternal_url.