Plan: Harden credentials and guarantee file persistence across updates
Goal
Ensure an app update (rebuild / redeploy / docker compose up -d) can never lose or
detach persistent data, and can never silently break the credentials the app uses to
reach that data:
- PostgreSQL — one explicit, fail-fast password variable shared by
dbandweb. - File-storage volumes — verify + document that
docs_data,media_data,static_data, andfastembed_cachesurvive updates. - S3 document-storage credentials — fail fast on a partial credential set instead of silently falling back to boto3's default chain.
Findings (verified in repo)
Postgres
- docker-compose.yml:10 — db uses POSTGRES_PASSWORD: ${SERVICE_PASSWORD_POSTGRES}
(opaque Coolify magic var, not operator-settable).
- docker-compose.yml:41 — web builds DATABASE_URL from
${SERVICE_PASSWORD_POSTGRES} with no default.
- .env.sample:33 and README.md:167 tell operators to set POSTGRES_PASSWORD, but
compose currently ignores it.
- config/settings.py:128 hard-requires DATABASE_URL (no fallback), so a mismatch is a
hard crash that looks like a lost DB.
- Postgres applies POSTGRES_PASSWORD only at initdb; changing it later leaves the
stored role password unchanged.
- Persistence is already correct: named volume pgdata (docker-compose.yml:12),
project name pinned (docker-compose.yml:1).
File-storage volumes
- docker-compose.yml:57-61 mounts named volumes media_data, static_data,
docs_data, fastembed_cache; declared at 63-68. Rebuilds/updates reuse them.
- Non-regenerable content: media_data (uploads), docs_data (local docs, seeded from
the image's docs/ only when the volume is first created).
- Regenerable: static_data (collectstatic), fastembed_cache (re-download).
- Only volume-destructive commands remove them (down -v, docker volume rm,
docker volume prune, or a Coolify volume-removal toggle).
S3
- config/settings.py:232-241 — DOCS_S3["ENABLED"] = bool(BUCKET).
- apps/documentation/services/s3.py:59-63 — credentials are passed to boto3 only when
both ACCESS_KEY_ID and SECRET_ACCESS_KEY are set; otherwise boto3 falls back to
its default chain, so a partial config fails at request time, not startup.
- docker-compose.yml:47-53 already passes ${DOCS_S3_*:-} explicitly (blank = local).
Decisions
| Decision | Choice |
|---|---|
| Postgres password source | Single POSTGRES_PASSWORD, used by db and web |
| Postgres strictness | Hard-required: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required (see .env.sample)} |
| Coolify magic var | Remove all SERVICE_PASSWORD_POSTGRES usage |
| File volumes | Keep named volumes; no compose change; document safeguards + docs_data seeding caveat |
| S3 credentials | All-or-nothing guard: raise ImproperlyConfigured when bucket is set and exactly one of key/secret is set; both blank keeps the default/IAM-role chain |
| Rotation | Out of scope; document that the DB password is fixed at first init |
Ordered tasks (atomic)
A. PostgreSQL credentials
-
dbpassword source —docker-compose.yml:10: replacePOSTGRES_PASSWORD: ${SERVICE_PASSWORD_POSTGRES}withPOSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required (see .env.sample)}. Success: withPOSTGRES_PASSWORDset,docker compose configshows the same value ondbandweb. -
webconnection string —docker-compose.yml:41: replace the${SERVICE_PASSWORD_POSTGRES}segment ofDATABASE_URLwith the same${POSTGRES_PASSWORD:?...}expression; keep the rest of the URL unchanged. Success:web'sDATABASE_URLembeds the identical password used bydb. -
Remove leftovers — search the repo for
SERVICE_PASSWORD_POSTGRES. Success: zero matches. -
.env.sampleguidance — nearPOSTGRES_PASSWORD(line 33): state it is set once before first start, must stay stable across updates, and that changing it afterpgdatais initialized requiresALTER USER ... WITH PASSWORD ...inside the DB. Keep the placeholder inDATABASE_URL(line 28) matching. Success: sample warns the value is captured at first init. -
README.mdDB note — under Option B → "Persistent data" (around 199-207): note thatPOSTGRES_PASSWORDis captured whenpgdatais first created and must stay stable; updating the app reuses the volume and does not reset it. Success: README states the password is fixed at first init.
B. File-storage volumes
-
Document persistence safeguards — extend the README "Persistent data" section: named volumes survive rebuilds; they are removed only by
docker compose down -v,docker volume rm <name>, ordocker volume prune. List the non-regenerable volumes (pgdata,media_data,docs_data) and the regenerable ones (static_data,fastembed_cache). State: never rundown -von production; in Coolify keep the "remove/delete volumes on redeploy" option disabled. Success: README names each volume and the exact destructive commands to avoid. -
Document the
docs_dataseeding caveat — in the same section, note thatdocs_datais seeded from the image'sdocs/only when the volume is first created, so later image updates do not refresh already-seeded content; usesync_docs/ re-import, or a bind mount, if bundleddocs/must track the image. Success: README explains why updated bundled docs may not appear.
(No docker-compose.yml change for B — volumes are already correct.)
C. S3 credentials
-
All-or-nothing guard — in
config/settings.pyimmediately after theDOCS_S3dict /ENABLEDassignment (around line 241), add: ifDOCS_S3["ENABLED"]and exactly one ofACCESS_KEY_ID/SECRET_ACCESS_KEYis non-empty, raisedjango.core.exceptions.ImproperlyConfigurednaming both variables. Both blank must remain valid (default/IAM-role chain); both set remains valid. Success: withDOCS_S3_BUCKETset and only the access key set, Django startup raises with a clear message; with both or neither set, startup is unaffected. -
.env.sampleS3 guidance — in the S3 block (lines 111-120): state that whenDOCS_S3_BUCKETis set,DOCS_S3_ACCESS_KEY_IDandDOCS_S3_SECRET_ACCESS_KEYmust be provided together, or both left blank to use the instance/default credential chain. Success: sample documents the all-or-nothing rule. -
As-built change note — add
docs/Developer/changes/<timestamp>-harden-credentials-and-file-persistence.mdfollowing the existing dated-change convention, recording: the Postgres variable rename + fail-fast choice, the documented volume safeguards, and the S3 guard. Success: the change note matches the shipped behavior.
Risks / notes
- Existing
pgdatavolume: ifPOSTGRES_PASSWORDdiffers from the value used at initdb, auth fails after the change. Set it to the existing password or runALTER USERin psql. This plan does not rotate the password. - Fail-fast by design: an update/deploy without
POSTGRES_PASSWORDnow fails atdocker compose config/uptime. Locally a.envis required; in Coolify the variable must be set in the resource UI. - S3 guard scope: it only catches a partial pair; it cannot detect a valid-but-wrong credential. It intentionally preserves boto3's default chain for role-based setups.
docs_dataseeding is existing Docker behavior, not caused by this change; the fix here is documentation only.- No schema impact: model field conventions and migrations are unaffected.
Validation (user-run — the agent does not test)
docker compose configwithPOSTGRES_PASSWORDset —dbandwebshow the same password.- Unset
POSTGRES_PASSWORD—docker compose configerrors with the fail-fast message. docker compose up -don the existing stack —webconnects todb; rows intact.docker compose exec db psql -U <user> -d <db> -c "\dt"lists Django tables.docker compose restart web(no-v) —docs_datafiles under/app/docsandmedia_datauploads are still present.- With
DOCS_S3_BUCKETset and onlyDOCS_S3_ACCESS_KEY_IDset, Django startup raisesImproperlyConfigured; with both or neither set, startup is unchanged. - Repo search confirms no
SERVICE_PASSWORD_POSTGRESremains.
Out of scope
- Automated backup/restore tooling (pg_dump schedules, S3 backups).
- Password rotation tooling or a role-password migration.
- Changing volume names, adding bind mounts, or modifying other services.
- Application/business-logic changes beyond the S3 config guard.
- Agent-run testing (the user is solely responsible for testing and validation).