diff --git a/.planning/phases/03-postgresql-support/03-RESEARCH.md b/.planning/phases/03-postgresql-support/03-RESEARCH.md
new file mode 100644
index 0000000..c427260
--- /dev/null
+++ b/.planning/phases/03-postgresql-support/03-RESEARCH.md
@@ -0,0 +1,575 @@
+# Phase 3: PostgreSQL Support - Research
+
+**Researched:** 2026-03-24
+**Domain:** Go database/sql with pgx/v5 + golang-migrate PostgreSQL dialect
+**Confidence:** HIGH
+
+## Summary
+
+Phase 3 adds PostgreSQL as an alternative backend alongside SQLite. The Store interface and all HTTP handlers are already dialect-neutral (Phase 2 delivered this). The work is entirely in three areas: (1) a new `PostgresStore` struct that implements the existing `Store` interface using PostgreSQL SQL syntax, (2) a separate migration runner for PostgreSQL using `golang-migrate`'s dedicated `pgx/v5` database driver, and (3) wiring in `main.go` to branch on `DATABASE_URL`.
+
+The critical dialect difference is `CreateTag`: PostgreSQL does not support `LastInsertId()` via `pgx/stdlib`. The `PostgresStore.CreateTag` method must use `QueryRow` with `RETURNING id` instead of `Exec` + `LastInsertId`. Every other SQL translation is mechanical (positional params, `NOW()`, `SERIAL`, `ON CONFLICT ... DO UPDATE` instead of `INSERT OR REPLACE`).
+
+The golang-migrate ecosystem ships a dedicated `database/pgx/v5` sub-package that wraps a `*sql.DB` opened via `pgx/v5/stdlib`. This fits the established pattern in `migrate.go` exactly — a new `RunPostgresMigrations(db *sql.DB) error` function using the same `iofs` source with an embedded `migrations/postgres` directory.
+
+**Primary recommendation:** Follow the locked decisions in CONTEXT.md verbatim. The implementation is a straightforward port of `SQLiteStore` with dialect adjustments; the only non-obvious trap is the `LastInsertId` incompatibility in `CreateTag`.
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**D-01:** Use `pgx/v5/stdlib` as the database/sql adapter — matches SQLiteStore's `*sql.DB` pattern so PostgresStore has the same constructor signature (`*sql.DB` in, Store out)
+**D-02:** Do NOT use pgx native interface directly — keeping both stores on `database/sql` means the Store interface stays unchanged and `NewServer(store Store, ...)` works identically
+**D-03:** Each store implementation has its own raw SQL — no runtime dialect switching, no query builder, no shared SQL templates
+**D-04:** PostgreSQL-specific syntax differences handled in PostgresStore methods:
+ - `SERIAL` instead of `INTEGER PRIMARY KEY AUTOINCREMENT` for tags.id
+ - `$1, $2, $3` positional params instead of `?` placeholders
+ - `NOW()` or `CURRENT_TIMESTAMP` instead of `datetime('now')` for acknowledged_at
+ - `ON CONFLICT ... DO UPDATE SET` syntax is compatible (PostgreSQL 9.5+)
+ - `INSERT ... ON CONFLICT DO UPDATE` for UPSERT (same pattern, different param style)
+ - `INSERT ... ON CONFLICT` for tag assignments instead of `INSERT OR REPLACE`
+**D-05:** PostgresStore does NOT use a mutex — PostgreSQL handles concurrent writes natively
+**D-06:** Use `database/sql` default pool settings with sensible overrides: `MaxOpenConns(25)`, `MaxIdleConns(5)`, `ConnMaxLifetime(5 * time.Minute)`
+**D-07:** `DATABASE_URL` env var present → PostgreSQL; absent → SQLite with `DB_PATH`
+**D-08:** No separate `DB_DRIVER` variable — the presence of `DATABASE_URL` is the switch
+**D-09:** Startup log clearly indicates which backend is active: `"Using PostgreSQL database"` vs `"Using SQLite database at {path}"`
+**D-10:** Separate migration directories: `migrations/sqlite/` (exists) and `migrations/postgres/` (new)
+**D-11:** PostgreSQL baseline migration `0001_initial_schema.up.sql` creates the same 3 tables with PostgreSQL-native types
+**D-12:** `RunMigrations` becomes dialect-aware or split into `RunSQLiteMigrations`/`RunPostgresMigrations` — researcher should determine best approach (see Architecture Patterns below)
+**D-13:** PostgreSQL migrations embedded via separate `//go:embed migrations/postgres` directive
+**D-14:** Use Docker Compose profiles — `docker compose --profile postgres up` activates the postgres service
+**D-15:** Default compose (no profile) remains SQLite-only for simple deploys
+**D-16:** Compose file includes a `postgres` service with health check, and the app service gets `DATABASE_URL` when the profile is active
+**D-17:** PostgresStore integration tests use a `//go:build postgres` build tag — they only run when a PostgreSQL instance is available
+**D-18:** CI can optionally run `-tags postgres` with a postgres service container; SQLite tests always run
+**D-19:** Test helper `NewTestPostgresServer()` creates a test database and runs migrations, similar to `NewTestServer()` for SQLite
+
+### Claude's Discretion
+- Exact PostgreSQL connection pool tuning beyond the defaults in D-06
+- Whether to split RunMigrations into two functions or use a dialect parameter
+- Error message formatting for PostgreSQL connection failures
+- Whether to add a health check endpoint that verifies database connectivity
+
+### Deferred Ideas (OUT OF SCOPE)
+None — discussion stayed within phase scope.
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| DB-01 | PostgreSQL is supported as an alternative to SQLite via pgx v5 driver | `pgx/v5/stdlib` confirmed pure-Go, `*sql.DB` compatible; `PostgresStore` implements all 9 Store methods |
+| DB-02 | Database backend is selected via DATABASE_URL env var (present = PostgreSQL, absent = SQLite with DB_PATH) | main.go branching pattern documented; driver registration names confirmed: `"sqlite"` and `"pgx"` |
+| DB-03 | Existing SQLite users can upgrade without data loss (baseline migration represents current schema) | SQLite migration already uses `CREATE TABLE IF NOT EXISTS`; PostgreSQL migration is a fresh baseline for new deployments; no cross-dialect migration needed |
+
+
+---
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `github.com/jackc/pgx/v5` | v5.9.1 (Mar 22 2026) | PostgreSQL driver + `database/sql` adapter via `pgx/v5/stdlib` | De-facto standard Go PostgreSQL driver; pure Go (no CGO); actively maintained; 8,394 packages import it |
+| `github.com/golang-migrate/migrate/v4/database/pgx/v5` | v4.19.1 (same module as existing golang-migrate) | golang-migrate database driver for pgx v5 | Already in project; dedicated pgx/v5 sub-package fits existing `migrate.go` pattern exactly |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `github.com/golang-migrate/migrate/v4/source/iofs` | v4.19.1 (already imported) | Serve embedded FS migration files | Reuse existing pattern from `migrate.go` |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `pgx/v5/stdlib` (`database/sql`) | pgx native interface | Native pgx is faster but breaks `Store` interface — rejected by D-02 |
+| `golang-migrate database/pgx/v5` | `golang-migrate database/postgres` | `database/postgres` uses `lib/pq` internally; `database/pgx/v5` uses pgx consistently — use pgx/v5 sub-package |
+| Two separate `RunMigrations` functions | Single function with dialect param | Two functions is simpler, avoids string-switch, each can be `go:embed`-scoped independently — use two functions (see Architecture) |
+
+**Installation:**
+```bash
+go get github.com/jackc/pgx/v5@v5.9.1
+go get github.com/golang-migrate/migrate/v4/database/pgx/v5
+```
+
+Note: `golang-migrate/migrate/v4` is already in `go.mod` at v4.19.1. Adding the `database/pgx/v5` sub-package pulls from the same module version — no module version conflict.
+
+**Version verification (current as of 2026-03-24):**
+- `pgx/v5`: v5.9.1 — verified via pkg.go.dev versions tab
+- `golang-migrate/v4`: v4.19.1 — already in go.mod
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure
+```
+pkg/diunwebhook/
+├── store.go # Store interface (unchanged)
+├── sqlite_store.go # SQLiteStore (unchanged)
+├── postgres_store.go # PostgresStore (new)
+├── migrate.go # Split: RunSQLiteMigrations + RunPostgresMigrations
+├── migrations/
+│ ├── sqlite/
+│ │ ├── 0001_initial_schema.up.sql (exists)
+│ │ └── 0001_initial_schema.down.sql (exists)
+│ └── postgres/
+│ ├── 0001_initial_schema.up.sql (new)
+│ └── 0001_initial_schema.down.sql (new)
+├── diunwebhook.go (unchanged)
+└── export_test.go # Add NewTestPostgresServer (build-tagged)
+cmd/diunwebhook/
+└── main.go # Add DATABASE_URL branching
+compose.yml # Add postgres profile
+compose.dev.yml # Add postgres profile
+```
+
+### Pattern 1: PostgresStore Constructor (no mutex, pool config)
+
+**What:** Constructor opens pool, sets sensible limits, no mutex (PostgreSQL serializes writes natively).
+**When to use:** Called from `main.go` when `DATABASE_URL` is present.
+
+```go
+// Source: CONTEXT.md D-05, D-06 + established SQLiteStore pattern in sqlite_store.go
+package diunwebhook
+
+import (
+ "database/sql"
+ "time"
+)
+
+type PostgresStore struct {
+ db *sql.DB
+}
+
+func NewPostgresStore(db *sql.DB) *PostgresStore {
+ db.SetMaxOpenConns(25)
+ db.SetMaxIdleConns(5)
+ db.SetConnMaxLifetime(5 * time.Minute)
+ return &PostgresStore{db: db}
+}
+```
+
+### Pattern 2: RunPostgresMigrations (separate function, separate embed)
+
+**What:** A dedicated migration runner for PostgreSQL using `golang-migrate`'s `database/pgx/v5` driver. Mirrors `RunMigrations` (which becomes `RunSQLiteMigrations`) exactly.
+**When to use:** Called from `main.go` after `sql.Open("pgx", databaseURL)` when `DATABASE_URL` is set.
+
+Decision D-12 leaves the split-vs-param choice to researcher. **Recommendation: two separate functions** (`RunSQLiteMigrations` and `RunPostgresMigrations`). Rationale: each function has its own `//go:embed` scope, there's no shared logic to deduplicate, and a string-switch approach adds a code path that can fail at runtime. Rename the existing `RunMigrations` to `RunSQLiteMigrations` for symmetry.
+
+```go
+// Source: migrate.go (existing pattern) + golang-migrate pgx/v5 docs
+//go:embed migrations/postgres
+var postgresMigrations embed.FS
+
+func RunPostgresMigrations(db *sql.DB) error {
+ src, err := iofs.New(postgresMigrations, "migrations/postgres")
+ if err != nil {
+ return err
+ }
+ driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
+ if err != nil {
+ return err
+ }
+ m, err := migrate.NewWithInstance("iofs", src, "pgx5", driver)
+ if err != nil {
+ return err
+ }
+ if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ return err
+ }
+ return nil
+}
+```
+
+Import alias: `pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"`.
+Driver name string for `NewWithInstance` is `"pgx5"` (matches the registration name in the pgx/v5 driver).
+
+### Pattern 3: CreateTag — RETURNING id (CRITICAL)
+
+**What:** PostgreSQL's pgx driver does not support `LastInsertId()`. `CreateTag` must use `QueryRow` with `RETURNING id`.
+**When to use:** In every `PostgresStore.CreateTag` implementation — this is the most error-prone difference from SQLiteStore.
+
+```go
+// Source: pgx issue #1483 + pkg.go.dev pgx/v5/stdlib docs
+func (s *PostgresStore) CreateTag(name string) (Tag, error) {
+ var id int
+ err := s.db.QueryRow(
+ `INSERT INTO tags (name) VALUES ($1) RETURNING id`, name,
+ ).Scan(&id)
+ if err != nil {
+ return Tag{}, err
+ }
+ return Tag{ID: id, Name: name}, nil
+}
+```
+
+### Pattern 4: AssignTag — ON CONFLICT DO UPDATE (replaces INSERT OR REPLACE)
+
+**What:** PostgreSQL does not have `INSERT OR REPLACE`. Use `INSERT ... ON CONFLICT (image) DO UPDATE SET tag_id = EXCLUDED.tag_id`.
+**When to use:** `PostgresStore.AssignTag`.
+
+```go
+// Source: CONTEXT.md D-04
+_, err := s.db.Exec(
+ `INSERT INTO tag_assignments (image, tag_id) VALUES ($1, $2)
+ ON CONFLICT (image) DO UPDATE SET tag_id = EXCLUDED.tag_id`,
+ image, tagID,
+)
+```
+
+### Pattern 5: main.go DATABASE_URL branching
+
+```go
+// Source: CONTEXT.md D-07, D-08, D-09
+databaseURL := os.Getenv("DATABASE_URL")
+var store diun.Store
+if databaseURL != "" {
+ db, err := sql.Open("pgx", databaseURL)
+ if err != nil {
+ log.Fatalf("sql.Open postgres: %v", err)
+ }
+ if err := diun.RunPostgresMigrations(db); err != nil {
+ log.Fatalf("RunPostgresMigrations: %v", err)
+ }
+ store = diun.NewPostgresStore(db)
+ log.Println("Using PostgreSQL database")
+} else {
+ dbPath := os.Getenv("DB_PATH")
+ if dbPath == "" {
+ dbPath = "./diun.db"
+ }
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ log.Fatalf("sql.Open sqlite: %v", err)
+ }
+ if err := diun.RunSQLiteMigrations(db); err != nil {
+ log.Fatalf("RunSQLiteMigrations: %v", err)
+ }
+ store = diun.NewSQLiteStore(db)
+ log.Printf("Using SQLite database at %s", dbPath)
+}
+```
+
+Add `_ "github.com/jackc/pgx/v5/stdlib"` import to `main.go` (blank import registers the `"pgx"` driver name).
+
+### Pattern 6: Docker Compose postgres profile
+
+```yaml
+# compose.yml — adds postgres profile without breaking default SQLite deploy
+services:
+ app:
+ image: gitea.jeanlucmakiola.de/makiolaj/diundashboard:latest
+ ports:
+ - "8080:8080"
+ environment:
+ - WEBHOOK_SECRET=${WEBHOOK_SECRET:-}
+ - PORT=${PORT:-8080}
+ - DB_PATH=/data/diun.db
+ - DATABASE_URL=${DATABASE_URL:-}
+ volumes:
+ - diun-data:/data
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ required: false # only enforced when postgres profile is active
+
+ postgres:
+ image: postgres:17-alpine
+ profiles:
+ - postgres
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-diun}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-diun}
+ POSTGRES_DB: ${POSTGRES_DB:-diundashboard}
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-diun}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+ restart: unless-stopped
+
+volumes:
+ diun-data:
+ postgres-data:
+ profiles:
+ - postgres
+```
+
+Activate with: `docker compose --profile postgres up -d`
+
+### Pattern 7: Build-tagged PostgreSQL integration tests
+
+```go
+// Source: CONTEXT.md D-17, D-19 + export_test.go pattern
+//go:build postgres
+
+package diunwebhook
+
+import (
+ "database/sql"
+ "os"
+ _ "github.com/jackc/pgx/v5/stdlib"
+)
+
+func NewTestPostgresServer() (*Server, error) {
+ databaseURL := os.Getenv("TEST_DATABASE_URL")
+ if databaseURL == "" {
+ databaseURL = "postgres://diun:diun@localhost:5432/diundashboard_test?sslmode=disable"
+ }
+ db, err := sql.Open("pgx", databaseURL)
+ if err != nil {
+ return nil, err
+ }
+ if err := RunPostgresMigrations(db); err != nil {
+ return nil, err
+ }
+ store := NewPostgresStore(db)
+ return NewServer(store, ""), nil
+}
+```
+
+### Anti-Patterns to Avoid
+- **Using `res.LastInsertId()` after `db.Exec`**: pgx does not implement this — returns an error at runtime. Use `QueryRow(...).Scan(&id)` with `RETURNING id` instead.
+- **Sharing the mutex with PostgresStore**: PostgreSQL handles concurrent writes; adding a mutex is unnecessary and hurts performance.
+- **Using `INSERT OR REPLACE`**: Not valid PostgreSQL syntax. Use `INSERT ... ON CONFLICT ... DO UPDATE SET`.
+- **Using `datetime('now')`**: SQLite function — not valid in PostgreSQL. Use `NOW()` or `CURRENT_TIMESTAMP`.
+- **Using `?` placeholders**: Not valid in PostgreSQL. Use `$1`, `$2`, etc.
+- **Using `INTEGER PRIMARY KEY AUTOINCREMENT`**: Not valid in PostgreSQL. Use `SERIAL` or `BIGSERIAL`.
+- **Forgetting `//go:build postgres` on test files**: Without the build tag, the test file will be compiled for all builds — `pgx/v5/stdlib` import will fail on SQLite-only CI runs.
+- **Calling `RunSQLiteMigrations` on a PostgreSQL connection**: The sqlite migration driver will fail to initialize against a PostgreSQL database.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| PostgreSQL migration tracking | Custom `schema_version` table | `golang-migrate/v4/database/pgx/v5` | Handles dirty state, locking, version history, rollbacks — all already solved |
+| Connection pooling | Custom pool implementation | `database/sql` built-in pool + `pgx/v5/stdlib` | `database/sql` pool is production-grade; pgx stdlib wraps it correctly |
+| Connection string parsing | Custom URL parser | Pass `DATABASE_URL` directly to `sql.Open("pgx", url)` | pgx parses standard PostgreSQL URI format natively |
+| Dialect detection at runtime | Inspect driver name at query time | Separate store structs with their own SQL | Runtime dialect switching creates test surface, runtime failures; two structs is simpler |
+
+**Key insight:** The existing `Store` interface already separates the concern — `PostgresStore` is just another implementation. There is nothing to invent.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: LastInsertId on PostgreSQL
+**What goes wrong:** `CreateTag` calls `res.LastInsertId()` — pgx returns `ErrNoLastInsertId` at runtime, not compile time.
+**Why it happens:** The `database/sql` `Result` interface defines `LastInsertId()` but pgx does not support it. SQLite does.
+**How to avoid:** In `PostgresStore.CreateTag`, use `QueryRow(...RETURNING id...).Scan(&id)` instead of `Exec` + `LastInsertId`.
+**Warning signs:** Test passes compile, panics or returns error at runtime on tag creation.
+
+### Pitfall 2: golang-migrate driver name mismatch
+**What goes wrong:** Passing the wrong database name string to `migrate.NewWithInstance` causes "unknown driver" errors.
+**Why it happens:** The `golang-migrate/database/pgx/v5` driver registers as `"pgx5"`, not `"pgx"` or `"postgres"`.
+**How to avoid:** Use `"pgx5"` as the database name arg to `migrate.NewWithInstance("iofs", src, "pgx5", driver)`.
+**Warning signs:** `migrate.NewWithInstance` returns an error mentioning an unknown driver.
+
+### Pitfall 3: pgx/v5/stdlib import not registered
+**What goes wrong:** `sql.Open("pgx", url)` fails with `"unknown driver pgx"`.
+**Why it happens:** The `"pgx"` driver is only registered when `pgx/v5/stdlib` is imported (blank import side effect).
+**How to avoid:** Add `_ "github.com/jackc/pgx/v5/stdlib"` to `main.go` and to any test files that open a `"pgx"` connection.
+**Warning signs:** Runtime error "unknown driver pgx" despite pgx being in go.mod.
+
+### Pitfall 4: SQLite `migrate.go` import conflict
+**What goes wrong:** Adding the pgx/v5 migrate driver import to `migrate.go` introduces pgx as a dependency of the SQLite migration path.
+**Why it happens:** Go imports are file-scoped; putting both drivers in one file compiles both.
+**How to avoid:** Put `RunSQLiteMigrations` and `RunPostgresMigrations` in separate files, or at minimum keep the blank driver import for pgx only in the PostgreSQL branch. Alternatively, keep both in `migrate.go` — both drivers are compiled into the binary regardless; this is a binary size trade-off, not a correctness issue.
+**Warning signs:** `modernc.org/sqlite` and `pgx` both appear in a file that should only need one.
+
+### Pitfall 5: Docker Compose `required: false` on depends_on
+**What goes wrong:** `app` service fails to start when postgres profile is inactive because `depends_on.postgres` is unconditional.
+**Why it happens:** `depends_on` without `required: false` makes the dependency mandatory even when the postgres profile is not active.
+**How to avoid:** Use `depends_on.postgres.required: false` so the health check dependency is only enforced when the postgres service is actually started. Requires Docker Compose v2.20+.
+**Warning signs:** `docker compose up` (no profile) fails with "service postgres not found".
+
+### Pitfall 6: GetUpdates timestamp scanning differences
+**What goes wrong:** `GetUpdates` scans `received_at` and `created` as strings (`createdStr`, `receivedStr`) and then calls `time.Parse(time.RFC3339, ...)`. In the PostgreSQL schema these columns are `TEXT` (by design), so scanning behaves the same. If someone types them as `TIMESTAMPTZ` instead, scanning into a string breaks.
+**Why it happens:** The SQLiteStore scans timestamps as strings because SQLite stores them as TEXT. If the PostgreSQL migration uses `TEXT` for these columns (matching the SQLite schema), the existing scan logic works unchanged in `PostgresStore`.
+**How to avoid:** Use `TEXT NOT NULL` for `received_at`, `acknowledged_at`, and `created` in the PostgreSQL migration, mirroring the SQLite schema exactly. Do not use `TIMESTAMPTZ` unless you also update the scan/format logic.
+**Warning signs:** `sql: Scan error ... converting driver.Value type time.Time into *string`.
+
+---
+
+## Code Examples
+
+### PostgreSQL baseline migration (0001_initial_schema.up.sql)
+```sql
+-- Source: sqlite/0001_initial_schema.up.sql translated to PostgreSQL dialect
+CREATE TABLE IF NOT EXISTS updates (
+ image TEXT PRIMARY KEY,
+ diun_version TEXT NOT NULL DEFAULT '',
+ hostname TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT '',
+ provider TEXT NOT NULL DEFAULT '',
+ hub_link TEXT NOT NULL DEFAULT '',
+ mime_type TEXT NOT NULL DEFAULT '',
+ digest TEXT NOT NULL DEFAULT '',
+ created TEXT NOT NULL DEFAULT '',
+ platform TEXT NOT NULL DEFAULT '',
+ ctn_name TEXT NOT NULL DEFAULT '',
+ ctn_id TEXT NOT NULL DEFAULT '',
+ ctn_state TEXT NOT NULL DEFAULT '',
+ ctn_status TEXT NOT NULL DEFAULT '',
+ received_at TEXT NOT NULL,
+ acknowledged_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS tags (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS tag_assignments (
+ image TEXT PRIMARY KEY,
+ tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
+);
+```
+
+Key differences from SQLite version:
+- `SERIAL PRIMARY KEY` replaces `INTEGER PRIMARY KEY AUTOINCREMENT`
+- All other columns are identical (`TEXT` type used throughout)
+- `ON DELETE CASCADE` is the same — PostgreSQL enforces FK constraints by default (no equivalent of `PRAGMA foreign_keys = ON` needed)
+
+### PostgreSQL down migration (0001_initial_schema.down.sql)
+```sql
+DROP TABLE IF EXISTS tag_assignments;
+DROP TABLE IF EXISTS tags;
+DROP TABLE IF EXISTS updates;
+```
+Identical to SQLite version.
+
+### UpsertEvent (PostgreSQL)
+```go
+// Positional params $1..$15, acknowledged_at reset to NULL on conflict
+_, err := s.db.Exec(`
+ INSERT INTO updates (
+ image, diun_version, hostname, status, provider,
+ hub_link, mime_type, digest, created, platform,
+ ctn_name, ctn_id, ctn_state, ctn_status,
+ received_at, acknowledged_at
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NULL)
+ ON CONFLICT(image) DO UPDATE SET
+ diun_version = EXCLUDED.diun_version,
+ hostname = EXCLUDED.hostname,
+ status = EXCLUDED.status,
+ provider = EXCLUDED.provider,
+ hub_link = EXCLUDED.hub_link,
+ mime_type = EXCLUDED.mime_type,
+ digest = EXCLUDED.digest,
+ created = EXCLUDED.created,
+ platform = EXCLUDED.platform,
+ ctn_name = EXCLUDED.ctn_name,
+ ctn_id = EXCLUDED.ctn_id,
+ ctn_state = EXCLUDED.ctn_state,
+ ctn_status = EXCLUDED.ctn_status,
+ received_at = EXCLUDED.received_at,
+ acknowledged_at = NULL`,
+ event.Image, event.DiunVersion, ...
+)
+```
+
+### AcknowledgeUpdate (PostgreSQL)
+```go
+// NOW() replaces datetime('now'), $1 replaces ?
+res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = NOW() WHERE image = $1`, image)
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `lib/pq` (archived) | `pgx/v5/stdlib` | pgx v4→v5, lib/pq archived ~2023 | pgx is now the consensus standard Go PostgreSQL driver |
+| `golang-migrate database/postgres` (uses lib/pq) | `golang-migrate database/pgx/v5` | golang-migrate added pgx/v5 sub-package | Use the pgx-native driver to avoid a lib/pq dependency |
+| Single global `RunMigrations` | Separate `RunSQLiteMigrations` / `RunPostgresMigrations` | This phase | Each function owns its embed directive and driver import |
+
+---
+
+## Open Questions
+
+1. **Rename `RunMigrations` to `RunSQLiteMigrations`**
+ - What we know: `RunMigrations` is only called in `main.go` and `export_test.go`. Renaming breaks two call sites.
+ - What's unclear: Whether to rename (consistency) or keep old name and add a new `RunPostgresMigrations` (backward compatible for hypothetical external callers).
+ - Recommendation: Rename to `RunSQLiteMigrations` — this is internal-only code and symmetry aids comprehension. Update the two call sites.
+
+2. **`depends_on.required: false` Docker Compose version requirement**
+ - What we know: `required: false` under `depends_on` was added in Docker Compose v2.20.
+ - What's unclear: Whether the target deployment environment has Compose v2.20+. Docker 29.0.0 (confirmed present) ships with Compose v2.29+ — this is not a concern for the dev machine. Production deployments depend on the user's Docker version.
+ - Recommendation: Use `required: false`; document minimum Docker Compose v2.20 in compose.yml comment.
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Docker | Compose postgres profile, integration tests | ✓ | 29.0.0 | — |
+| PostgreSQL server | Integration test execution (`-tags postgres`) | ✗ | — | Tests skip via build tag; Docker Compose spins up postgres for CI |
+| `pg_isready` / psql client | Health check inside postgres container | ✗ (host) | — | `pg_isready` is inside the `postgres:17-alpine` image — not needed on host |
+| Go 1.26 | Build | Not directly measurable from this shell | go.mod specifies 1.26 | — |
+
+**Missing dependencies with no fallback:**
+- None that block development. PostgreSQL integration tests require a live server but are gated behind `//go:build postgres`.
+
+**Missing dependencies with fallback:**
+- PostgreSQL server (host): not installed, but not required — tests use build tags, Docker Compose provides the server for integration runs.
+
+---
+
+## Project Constraints (from CLAUDE.md)
+
+Directives the planner must verify compliance with:
+
+- **No CGO**: `CGO_ENABLED=0` in Dockerfile Stage 2. `pgx/v5` is pure Go — this constraint is satisfied. Verify that adding `pgx/v5` does not transitively pull in any CGO package.
+- **Pure Go SQLite driver**: `modernc.org/sqlite` must remain. Adding pgx does not replace it — both coexist.
+- **Database must support both SQLite and PostgreSQL**: This is exactly what Phase 3 delivers via the Store interface.
+- **`database/sql` abstraction**: Both stores use `*sql.DB`. No pgx native interface in handlers.
+- **`net/http` only, no router framework**: No impact from this phase.
+- **`gofmt` enforced**: All new `.go` files must be `gofmt`-clean.
+- **Naming conventions**: New file `postgres_store.go`, new type `PostgresStore`, new constructor `NewPostgresStore`. Test helper `NewTestPostgresServer`. Functions `RunSQLiteMigrations` / `RunPostgresMigrations`.
+- **Error handling**: `http.Error(w, ..., status)` with lowercase messages. Not directly affected — PostgresStore is storage-layer only. `log.Fatalf` in `main.go` for connection/migration failures (matches existing pattern).
+- **No global state**: `PostgresStore` holds `*sql.DB` as struct field, no package-level vars — consistent with Phase 2 refactor.
+- **GSD workflow**: Do not make direct edits outside a GSD phase.
+- **Module name**: `awesomeProject` (in go.mod). Import as `diun "awesomeProject/pkg/diunwebhook"` in main.go.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- pkg.go.dev/github.com/jackc/pgx/v5 — version confirmed v5.9.1 (Mar 22 2026), stdlib package import path, driver name `"pgx"`, pure Go confirmed
+- pkg.go.dev/github.com/jackc/pgx/v5/stdlib — `sql.Open("pgx", url)` pattern, `LastInsertId` not supported
+- pkg.go.dev/github.com/golang-migrate/migrate/v4/database/pgx/v5 — `WithInstance(*sql.DB, *Config)`, driver registers as `"pgx5"`, v4.19.1
+- github.com/golang-migrate/migrate/blob/master/database/pgx/v5/pgx.go — confirmed `database.Register("pgx5", &db)` registration name
+- Existing codebase: `store.go`, `sqlite_store.go`, `migrate.go`, `export_test.go`, `main.go` — all read directly
+
+### Secondary (MEDIUM confidence)
+- github.com/jackc/pgx/issues/1483 — `LastInsertId` not supported by pgx, confirmed by multiple sources
+- Docker Compose docs (docs.docker.com/reference/compose-file/services/) — profiles syntax, depends_on with required: false
+
+### Tertiary (LOW confidence)
+- WebSearch results re: Docker Compose `required: false` version requirement — states Compose v2.20; not independently verified against official changelog. However, Docker 29.0.0 (installed) ships Compose v2.29+, so this is moot for the dev machine.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — versions verified via pkg.go.dev on 2026-03-24
+- Architecture: HIGH — based on existing codebase patterns + confirmed library APIs
+- Pitfalls: HIGH for LastInsertId, driver name, import registration (all verified via official sources); MEDIUM for Docker Compose `required: false` version boundary
+
+**Research date:** 2026-03-24
+**Valid until:** 2026-05-24 (stable ecosystem; pgx and golang-migrate release infrequently)