# ADR-005: PostgreSQL 18 sole canonical DB - Status: Accepted - Date: 2026-08-31 - Deciders: platform stream - References: ADR index (section 70), Architecture wiki (sections 1, 4, 9), Technology-Stack wiki (sections 5.2, 5.4, 6, 7), Engineering-Standards wiki (sections 24, 25, 58) ## Context EPPP is a greenfield personal blogging platform owned by a single small stream. The first visible release (v0.1) is intentionally small, but the codebase ships explicit, versioned boundaries from day one — content types, content blocks, page sections, themes, extensions, extension settings, navigation, visitor preferences, database migrations, media storage, background jobs, events and rendering — and all of these need durable, transactional storage. ADR-001 already records that the platform operates a single canonical PostgreSQL database within a modular monolith; this ADR is the focused record of the database choice itself. There is no v1 requirement for distributed transactions, service discovery, message brokers or independent data stores, so the database can be one engine, one instance, one consistency boundary. The choice is already operationalised in the workspace: - `compose.yaml` pins `postgres:18.6-bookworm` as the documented runtime target (Technology-Stack §5.2/§5.4, golden tuple §7; E00-S03-T01), with a `pg_isready` health gate and a named `db-data` volume for persistence. - `packages/database-postgres` is the single workspace package allowed to import `pg` and Kysely (dependency direction `database-postgres → core ports → Kysely/pg`, Engineering-Standards §24); domain and extension packages never import the driver. - The migration runner coordinates on a PostgreSQL advisory lock (`pg_advisory_lock`, E00-S03-T04) and gates app readiness on migration completion (E00-S03-T06). - Extensions are the growth mechanism; extension-owned tables and migrations live in independent chains (`eppp_extension_migrations`) sharing the same canonical database (ADR-001, Engineering-Standards §25). PostgreSQL 18 is Class A in the runtime stack (fixed upstream EOL date, Technology-Stack §5.1) with a documented support window to 2030-11-14; the LTS strategy mandates always running the current minor, with a major upgrade as a separate operator procedure (Technology-Stack §6). ## Decision EPPP uses **PostgreSQL 18 as the sole canonical database**. One PostgreSQL 18 instance is the only database engine in the platform; it holds all core data, and extension-owned tables live in independent migration chains inside the same instance. `pg`/Kysely imports are isolated to `packages/database-postgres` — the adapter boundary every database access passes through — and no other database engine is introduced at v1. The decision text — **PostgreSQL 18 sole canonical DB** — matches the ADR index entry (ADR-005, section 70). ## Alternatives - **SQLite** — rejected: the embedded, file-backed, single-writer model does not fit the long-lived connection-backed server plus optional worker and background jobs, and it offers no advisory-lock coordination for concurrent migration runners; operational tooling for the Compose deployment model is weaker than PostgreSQL's. - **MySQL / MariaDB** — rejected: no v1 requirement needs their differentiators; PostgreSQL provides the standards compliance, constraints, JSONB and advisory locks that the migration runner and the versioned contract model rely on, and a second SQL dialect would add cost without benefit. - **MongoDB / document store** — rejected: no v1 need for schemaless documents; the platform's explicit versioned contracts (content types, blocks, schemas validated by TypeBox/Ajv) benefit from a relational, constraint-enforcing store. - **Polyglot persistence (multiple engines)** — rejected: no v1 requirement justifies the operational and cognitive cost; a single canonical database keeps one transaction and consistency boundary (ADR-001). - **Cloud-managed database (e.g. RDS/Aurora)** — rejected for v1: the primary runtime model is Docker Compose with local parity; a managed service can be revisited on evidence if hosting needs change. - **Older PostgreSQL major / unpinned minor** — rejected: 18 is the documented golden tuple; pinning the exact 18.6 minor makes the database container reproducible (E00-S03-T01). ## Consequences - Positive: one transaction and consistency boundary across the whole platform; mature operational tooling (standard backups, `pg_isready` health gates already in `compose.yaml`); advisory locks coordinate concurrent migration runners; constraints and JSONB support the versioned contract model; the adapter boundary keeps engine specifics contained in one package. - Negative: a heavier operational footprint than embedded options — a dedicated service with credentials, volume persistence and health/readiness gating; schema changes demand migration discipline (expand/contract, never edit a released migration — Engineering-Standards §25); the single canonical database is a single point of failure (mitigated by volume persistence, health gates and standard backup tooling). - Neutral: the engine is encapsulated behind core ports, so moving to a different engine later is a deliberate, evidence-based change rather than a default; extensions share the canonical database with their own migration chains. ## Operational impact - A dedicated `db` service in `compose.yaml`, pinned to `postgres:18.6-bookworm`, with a `pg_isready` healthcheck that gates app start and a named `db-data` volume that persists across restart/recreate (`docker compose down -v` resets it). - Credentials come from the environment (`DATABASE_URL`, Compose `POSTGRES_*` defaults), never embedded in the image (E00-S02-T08). - One core migration chain plus extension-owned chains (`eppp_extension_migrations`) run behind the advisory migration lock at startup; readiness is reported only after migrations complete (E00-S03-T06). - Backups use standard PostgreSQL tooling against the volume; version policy follows the LTS strategy — always run the current minor, treat a major upgrade as a separate operator procedure (Technology-Stack §6). - At v1 there is no replication, multi-instance or sharding; scaling stays vertical and the database remains the single shared store. ## Revisit trigger - Revisit this ADR when a module or extension needs a genuinely different data model (graph, search index, time-series) and there is evidence that PostgreSQL features are insufficient — per the architecture review gates (ADR index section 68); the adapter boundary keeps such a change contained. - Revisit if the platform grows to multiple independent products or teams requiring independent data stores or distributed transactions (shared with the ADR-001 revisit trigger). - Revisit if the golden tuple or Technology-Stack changes the database choice or its support window — for example when PostgreSQL 18 EOL (2030-11-14) approaches and a major upgrade becomes a scheduled operator procedure, or a new major becomes the documented runtime target.