feat(deploy): db-init one-shot service — paste-and-go bootstrap for the runtime role

The role split previously required a one-time manual script run on
existing volumes (initdb.d only fires on fresh ones). This folds the
bootstrap into the compose file the way split-account deployments
standardly do it: a one-shot db-init service runs z_rls_role.sh
(idempotent, network mode via PGHOST=db + PGPASSWORD from DBPASS) on
every 'compose up', and the app gates on
db-init: condition: service_completed_successfully.

- Zero manual steps for fresh AND existing deployments: update compose,
  add DBSRVPASS to .env, 'compose pull && up -d'.
- Security posture improves: the superuser password is now held ONLY by
  the init service — the app container connects exclusively as the
  non-superuser runtime role.
- z_rls_role.sh gains dual connection modes (local socket for docker
  exec, network for the init service) with the DBPASS requirement
  enforced only in network mode.
- Verified on the dev stack: init runs and exits clean, app gates on it
  and comes up healthy, second 'up' re-converges idempotently, invariant
  test passes through the new dependency chain.

The initdb.d mount stays as-is (harmless double coverage on fresh
volumes; both paths are idempotent).
This commit is contained in:
John O'Keefe committed 2026-10-29 11:17:42 -04:00
1 parent 1457dc7289
commit 313268a7ae
3 files changed
+41 -13

No files matched your search

+2 -3
View File
@@ -13,9 +13,8 @@ DBPASS=your-secure-database-password-here
# Runtime database role password (bookhoard_srv — the non-superuser # Runtime database role password (bookhoard_srv — the non-superuser
# account the app connects as; subject to RLS). Required for the app to # account the app connects as; subject to RLS). Required for the app to
# start. On a fresh volume it is consumed by database/schema/z_rls_role.sh # start. Consumed by the one-shot db-init service on every startup
# during first initialization; on an existing volume, run that script once # (idempotent — creates/refreshes the role and its privileges).
# (see its header) BEFORE starting the app with DATABASE_USER=bookhoard_srv.
# Generate with: openssl rand -hex 16 # Generate with: openssl rand -hex 16
DBSRVPASS=your-secure-runtime-role-password-here DBSRVPASS=your-secure-runtime-role-password-here
+16 -10
View File
@@ -9,15 +9,11 @@
# remains the cluster/database bootstrap account; the app never connects # remains the cluster/database bootstrap account; the app never connects
# as it. # as it.
# #
# Runs AUTOMATICALLY on fresh volumes: docker-entrypoint-initdb.d executes # Runs on EVERY `docker compose up` via the one-shot `db-init` service
# initdb.d entries in alphabetical order — "z_rls_role.sh" sorts AFTER # (idempotent — the app's depends_on gates on its success), AND on
# "schema.sql" (digits/uppercase sort before lowercase 's'), so the tables # fresh volumes via docker-entrypoint-initdb.d ('z' sorts after
# exist before ownership transfers. It runs as the postgres superuser with # 'schema.sql'). Manual equivalent for existing volumes:
# the db container's env (DBSRVPASS comes from .env via compose env_file). # docker exec -i -e DBSRVPASS=... bookhoard_db bash -s < database/schema/z_rls_role.sh
#
# EXISTING volumes (initdb.d never re-runs): apply manually ONCE, as the
# superuser, BEFORE switching the app's DATABASE_USER:
# docker exec -i bookhoard_db bash -s < database/schema/z_rls_role.sh
# #
# IDEMPOTENT: safe to re-run (role creation is guarded; ownership ALTERs # IDEMPOTENT: safe to re-run (role creation is guarded; ownership ALTERs
# and the password set are idempotent). # and the password set are idempotent).
@@ -30,7 +26,17 @@ set -euo pipefail
: "${POSTGRES_USER:?POSTGRES_USER must be set (the image sets it)}" : "${POSTGRES_USER:?POSTGRES_USER must be set (the image sets it)}"
: "${POSTGRES_DB:?POSTGRES_DB must be set (the image sets it)}" : "${POSTGRES_DB:?POSTGRES_DB must be set (the image sets it)}"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ # Connection mode: local socket (docker exec into the db container) or
# network (the db-init compose service, which sets PGHOST=db and needs
# the bootstrap password for scram auth).
PSQL_OPTS=(-v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB")
if [ -n "${PGHOST:-}" ]; then
: "${DBPASS:?DBPASS is required when connecting over the network (PGHOST set)}"
PSQL_OPTS+=(-h "$PGHOST")
export PGPASSWORD="$DBPASS"
fi
psql "${PSQL_OPTS[@]}" \
-v role_pass="$DBSRVPASS" <<'EOSQL' -v role_pass="$DBSRVPASS" <<'EOSQL'
\echo '== RLS role bootstrap: creating/refreshing bookhoard_srv ==' \echo '== RLS role bootstrap: creating/refreshing bookhoard_srv =='
+23
View File
@@ -26,6 +26,27 @@ services:
env_file: env_file:
- .env - .env
# One-shot bootstrap: creates the bookhoard_srv runtime role and
# transfers object ownership to it (RLS prerequisite). Idempotent —
# safe on every startup; the app waits for its success. Passwords
# accept inline values or .env, like the rest of this file.
db-init:
image: postgres:15-alpine
container_name: bookhoard_db_init
environment:
POSTGRES_USER: postgres
POSTGRES_DB: bookhoard
PGHOST: db
DBPASS: ${DBPASS}
DBSRVPASS: ${DBSRVPASS}
volumes:
- ./database/schema/z_rls_role.sh:/z_rls_role.sh:ro
command: bash /z_rls_role.sh
depends_on:
db:
condition: service_healthy
restart: "no"
# Bookhoard Application # Bookhoard Application
# In production this image is pulled from the Gitea container registry. # In production this image is pulled from the Gitea container registry.
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest"). # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
@@ -78,6 +99,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
db-init:
condition: service_completed_successfully
volumes: volumes:
- ./uploads:/app/uploads - ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub - bookhoard_conversion_cache:/app/cache/kepub