diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 2cb857975..985a0c309 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -79,6 +79,8 @@ updates:
# The first run of this ecosystem proposed 16.15 -> 18.6, which would
# have handed self-hosters a database their data directory cannot be
# read by. Minor releases still arrive as PRs; a major is a deliberate
- # project decision with a migration guide behind it.
+ # project decision with a migration guide behind it. The ignore also
+ # keeps `db-upgrade` on 16: it exists to read Postgres 16 data, and a
+ # bump to 18 would leave it unable to start on the volume it moves.
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
diff --git a/.github/workflows/compose-db-upgrade.yml b/.github/workflows/compose-db-upgrade.yml
new file mode 100644
index 000000000..edc86e0cb
--- /dev/null
+++ b/.github/workflows/compose-db-upgrade.yml
@@ -0,0 +1,51 @@
+name: Compose database move
+
+# Plays the Postgres 16 -> 18 move in docker-compose.yml against real
+# containers: a v1.45 database with every migration applied, then the current
+# compose file's `db-upgrade` on its first start, a second start, a move cut
+# short, an emptied new volume and a new install. The service runs once on an
+# operator's host and has no second chance there, so every change to the
+# compose file or to the check runs it here first.
+# `scripts/compose-db-upgrade-check.sh` is the same check, runnable locally.
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - docker-compose.yml
+ - scripts/compose-db-upgrade-check.sh
+ - .github/workflows/compose-db-upgrade.yml
+ push:
+ branches: [main]
+ paths:
+ - docker-compose.yml
+ - scripts/compose-db-upgrade-check.sh
+ - .github/workflows/compose-db-upgrade.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ move:
+ name: Postgres 16 -> 18 move
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ # The check reads the v1.45 compose file from its tag.
+ - name: Fetch the v1.45.0 tag
+ run: git fetch --no-tags --depth=1 origin tag v1.45.0
+
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
+
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ - name: Move check
+ run: bash scripts/compose-db-upgrade-check.sh
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 165aca665..0640e5420 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -40,7 +40,7 @@ jobs:
name: e2e shard ${{ matrix.shard }}/3
services:
postgres:
- image: postgres:16-alpine
+ image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -158,7 +158,7 @@ jobs:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
services:
postgres:
- image: postgres:16-alpine
+ image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml
index 4f4bdf70f..d22ef58f0 100644
--- a/.github/workflows/post-publish-verify.yml
+++ b/.github/workflows/post-publish-verify.yml
@@ -56,7 +56,7 @@ jobs:
services:
postgres:
- image: postgres:16-alpine
+ image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml
index 88c1e0a27..eddede148 100644
--- a/.github/workflows/test-nightly.yml
+++ b/.github/workflows/test-nightly.yml
@@ -84,7 +84,7 @@ jobs:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
- name: integration shard ${{ matrix.shard }}/4 (shuffled)
+ name: integration shard ${{ matrix.shard }}/4 (shuffled, Postgres 16)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -110,6 +110,10 @@ jobs:
shell: bash
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
+ # Pull requests run the suite on Postgres 18, the compose default.
+ # Installs that bring their own database may still be on 16, so the
+ # nightly run keeps that major covered without doubling every PR.
+ INTEGRATION_PG_IMAGE: "postgres:16-alpine"
# Four times each property's own case count; a property's timeout
# grows by the same factor (`propertyTimeout`).
FC_RUNS_FACTOR: "4"
diff --git a/README.md b/README.md
index 214f00045..854eb8cc3 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ curl -s http://localhost:3000/api/version # returns version + buildSha + built
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
-Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
+Persistent state lives in exactly one named volume (`postgres-data`, the Postgres data); the app container is stateless, so backing up the database backs up everything. An install from v1.45 or earlier moves its Postgres 16 database into it on the first start ([details](docs/ops/postgres-major-upgrade.md)).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
@@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl
## Tech stack
-Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
+Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
## Documentation
diff --git a/docker-compose.yml b/docker-compose.yml
index 01d9e0cd4..cdfbfeea6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,4 @@
-# HealthLog — two-container stack (app + Postgres 16).
+# HealthLog — two-container stack (app + Postgres 18).
#
# First run (clean host):
# cp .env.example .env
@@ -14,9 +14,11 @@
# and login silently fails. This is the single most common self-host snag.
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
-# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
-# data dir). The app container is stateless, so a backup of the database is
-# a backup of everything. The optional GeoLite2 databases are either baked
+# Persistent state lives in exactly one named volume (`postgres-data`,
+# Postgres' data). The app container is stateless, so a backup of the database
+# is a backup of everything. `pgdata` is where Postgres 16 kept the data up to
+# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
+# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). The optional GeoLite2 databases are either baked
# into the image at build time or mounted read-only from the host and
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
@@ -489,6 +491,8 @@ services:
depends_on:
db:
condition: service_healthy
+ db-upgrade:
+ condition: service_completed_successfully
restart: unless-stopped
# Bounded log rotation: the default json-file driver keeps every line
# forever, which is exactly what fills the disk on the small NAS /
@@ -562,6 +566,8 @@ services:
# depends_on:
# db:
# condition: service_healthy
+ # db-upgrade:
+ # condition: service_completed_successfully
# restart: unless-stopped
# logging:
# driver: json-file
@@ -572,13 +578,11 @@ services:
# stop_grace_period: 45s
db:
- # Pinned to the concrete minor instead of the floating `16-alpine`
+ # Pinned to the concrete minor instead of the floating `18-alpine`
# alias so a database update is always a deliberate, reviewable
- # change (at pin time `16-alpine` resolved to this same digest, so
- # existing deployments see no image change). Minor releases share
- # the on-disk format — bumping this pin is safe without a dump /
- # restore; a major-version bump (17+) is not, see
- # docs/ops/postgres-major-upgrade.md.
+ # change. Minor releases share the on-disk format, so bumping this pin
+ # is safe without a dump / restore; a major version is not, which is
+ # what `db-upgrade` below and docs/ops/postgres-major-upgrade.md are for.
#
# The pin only works as long as something watches it: PostgreSQL
# publishes minor releases with security and data-corruption fixes and
@@ -588,7 +592,7 @@ services:
# `.github/dependabot.yml` now raises a PR for each new minor;
# `src/__tests__/database-pin-is-watched.test.ts` keeps both halves
# (concrete pin, active watch) from being dropped.
- image: postgres:16.15-alpine
+ image: postgres:18.6-alpine
container_name: healthlog-db
# Loads pg_stat_statements so slow queries can be found from measurements
# instead of guesses. Loading the library only collects statistics; the
@@ -618,8 +622,12 @@ services:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: healthlog
+ # From 18 on, the image keeps its data under /var/lib/postgresql/18/docker
+ # and expects the volume one level up, so the next major can sit beside
+ # this one in the same volume. Mounting it at .../data, as 16 had it,
+ # makes the container refuse to start.
volumes:
- - pgdata:/var/lib/postgresql/data
+ - postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U healthlog"]
interval: 5s
@@ -640,5 +648,134 @@ services:
max-size: "10m"
max-file: "3"
+ # Moves a Postgres 16 database from v1.45 or earlier into Postgres 18, once,
+ # on the first start after the update. A major version cannot read the data
+ # directory of an older one, so changing the image line alone would leave
+ # the database refusing to start.
+ #
+ # On every start it decides in a second and exits:
+ # - no `pgdata` data (a new install): nothing to do;
+ # - Postgres 18 already holds the database: nothing to do;
+ # - otherwise it starts Postgres 16 inside this container on the old
+ # volume, streams a dump into the new database in one transaction,
+ # compares the row count of every table, gathers planner statistics and
+ # marks the old volume as moved. `app` starts only after it succeeded.
+ # A move cut short (a restart, a full disk) leaves a "moving" marker, and
+ # the next start empties the new database and runs it again from scratch.
+ #
+ # The old volume is read, never changed, apart from that marker file, so
+ # going back is the previous image and volume lines. If anything fails, the
+ # new database stays empty, `app` does not start, and the log says why.
+ # It costs one small image pull; a later release removes the service.
+ db-upgrade:
+ image: postgres:16.15-alpine
+ container_name: healthlog-db-upgrade
+ # Runs once per `compose up` and exits. "no" also keeps hosts such as
+ # Coolify from reporting the exited container as a failed service.
+ restart: "no"
+ security_opt:
+ - no-new-privileges:true
+ environment:
+ POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ # Read-only, only to check there is room for the copy before starting.
+ - postgres-data:/var/lib/postgresql/next:ro
+ depends_on:
+ db:
+ condition: service_healthy
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+ # `$$` is how compose spells a literal `$`.
+ entrypoint: ["/bin/sh", "-c"]
+ command:
+ - |
+ set -eu
+ set -o pipefail
+ log() { echo "db-upgrade: $$*"; }
+ OLD=/var/lib/postgresql/data
+ MARK="$$OLD/HEALTHLOG_MOVED_TO_POSTGRES_18"
+ MOVING="$$OLD/HEALTHLOG_MOVING_TO_POSTGRES_18"
+ export PGPASSWORD="$$POSTGRES_PASSWORD"
+ new() { psql -h db -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
+ old() { psql -h /tmp -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
+ empty_new() {
+ for S in $$(new "SELECT quote_ident(nspname) FROM pg_namespace WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'"); do
+ new "DROP SCHEMA $$S CASCADE"
+ done
+ new "CREATE SCHEMA public"
+ }
+ if [ ! -s "$$OLD/PG_VERSION" ]; then
+ log "no Postgres 16 database to move, nothing to do"
+ exit 0
+ fi
+ USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
+ TABLES=$$(new "SELECT count(*) $$USER_TABLES")
+ if [ -e "$$MARK" ]; then
+ if [ "$$TABLES" != 0 ]; then
+ log "the Postgres 16 database was moved on $$(cat "$$MARK"), nothing to do"
+ exit 0
+ fi
+ log "the Postgres 16 database was moved on $$(cat "$$MARK"), but the Postgres 18 database is empty now."
+ log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"."
+ exit 1
+ fi
+ if [ -e "$$MOVING" ]; then
+ log "an earlier move did not finish; emptying the Postgres 18 database and starting it again"
+ empty_new
+ elif [ "$$TABLES" != 0 ]; then
+ log "the Postgres 18 database already holds data, leaving both as they are"
+ exit 0
+ fi
+ VERSION=$$(cat "$$OLD/PG_VERSION")
+ if [ "$$VERSION" != 16 ]; then
+ log "the old data directory is Postgres $$VERSION, this step moves Postgres 16 only."
+ log "See docs/ops/postgres-major-upgrade.md to move it by hand."
+ exit 1
+ fi
+ NEED=$$(du -sk "$$OLD" | cut -f1)
+ FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }')
+ if [ "$$FREE" -le "$$NEED" ]; then
+ log "the move needs about $$((NEED / 1024)) MiB free for the new database, the disk has $$((FREE / 1024)) MiB."
+ log "Nothing was changed. Free some space and start the stack again."
+ exit 1
+ fi
+ trap 'CODE=$$?; [ "$$CODE" = 0 ] || log "the move stopped (exit $$CODE). The old volume is untouched; the next start begins it again."' EXIT
+ date -u +%Y-%m-%dT%H:%M:%SZ > "$$MOVING"
+ log "moving the Postgres 16 database ($$((NEED / 1024)) MiB on disk) into Postgres 18"
+ su-exec postgres postgres -D "$$OLD" -c listen_addresses= -c unix_socket_directories=/tmp &
+ PID=$$!
+ i=0
+ until pg_isready -h /tmp -U healthlog -q; do
+ kill -0 "$$PID" 2>/dev/null || { log "the Postgres 16 server did not start, see the lines above"; exit 1; }
+ i=$$((i + 1))
+ [ "$$i" -lt 600 ] || { log "the Postgres 16 server did not get ready in ten minutes"; exit 1; }
+ sleep 1
+ done
+ pg_dump -h /tmp -U healthlog -Fc healthlog |
+ pg_restore -h db -U healthlog -d healthlog --no-owner --single-transaction --exit-on-error
+ COUNTED=0
+ for T in $$(old "SELECT quote_ident(schemaname) || '.' || quote_ident(tablename) $$USER_TABLES ORDER BY 1"); do
+ A=$$(old "SELECT count(*) FROM $$T")
+ B=$$(new "SELECT count(*) FROM $$T")
+ if [ "$$A" != "$$B" ]; then
+ log "table $$T has $$A rows in Postgres 16 but $$B in Postgres 18. Emptying the new database again."
+ log "Please report this with the lines above."
+ empty_new
+ exit 1
+ fi
+ COUNTED=$$((COUNTED + 1))
+ done
+ log "all $$COUNTED tables arrived with the same row counts"
+ vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet
+ kill -INT "$$PID"
+ wait "$$PID" || true
+ mv "$$MOVING" "$$MARK"
+ log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
+
volumes:
+ postgres-data:
pgdata:
diff --git a/docs/contributing/benchmarks.md b/docs/contributing/benchmarks.md
index aa10a1d73..9ceb4b7a0 100644
--- a/docs/contributing/benchmarks.md
+++ b/docs/contributing/benchmarks.md
@@ -14,7 +14,7 @@ Never point the seed or the benchmark at a database that holds real data. They w
```sh
docker run -d --name hl-bench-db --shm-size=256m \
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
- -p 55432:5432 postgres:16.15-alpine \
+ -p 55432:5432 postgres:18.6-alpine \
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
```
diff --git a/docs/diagrams/01-data-flow.svg b/docs/diagrams/01-data-flow.svg
index 9bb7a272b..2a4b04956 100644
--- a/docs/diagrams/01-data-flow.svg
+++ b/docs/diagrams/01-data-flow.svg
@@ -81,7 +81,7 @@
pre-aggregated buckets
- PostgreSQL 16
+ PostgreSQL 18encrypted at rest
diff --git a/docs/diagrams/03-self-hosting-topology.svg b/docs/diagrams/03-self-hosting-topology.svg
index 658a87b7b..0b860ce2b 100644
--- a/docs/diagrams/03-self-hosting-topology.svg
+++ b/docs/diagrams/03-self-hosting-topology.svg
@@ -1,6 +1,6 @@