Add a Docker Compose Postgres for local development (Vikunja task #4) #7
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/docker-compose"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements Vikunja task #4. Provides the local database so developers do not install Postgres by hand.
Stock
postgres:18.4-trixie— no PostGIS. The ticket says "optional PostGIS image, pending decision"; that decision was taken and is recorded in ADR-0006. The ticket text is stale and should be corrected.Correction to this description's original claim
The first version of this PR said the wrong volume path would "silently persist nothing" and called it a silently-ineffective setting. Review disproved the mechanism by building a throwaway compose file mounting the legacy path and running it. It does not silently lose data — the image aborts on the first
up:Recording the correction rather than editing it away. One nuance found while fixing it, which is why the file's comment is not simply "it fails loudly":
docker compose up -dstill printsContainer ... Startedand exits 0. The failure is loud in the container logs and invisible at the prompt. The comment now states both halves.The volume path
Mounts
/var/lib/postgresql, not/var/lib/postgresql/data. Postgres 18 setsPGDATA=/var/lib/postgresql/18/dockerand declares itsVOLUMEone level up; the pre-18 convention that most tutorials still show is wrong for this image. Confirmed bydocker image inspectin three independent runs, and the failure mode reproduced twice.Verification actually performed
docker compose configvalidates; norestartkey;host_ip: 127.0.0.1; volume target/var/lib/postgresql.select version()returns PostgreSQL 18.4.ss -ltnshowsLISTEN 127.0.0.1:5432; a TCP connect to the LAN address is refused.down/upby writing and reading back a row — re-run after every change..envoverride proven — non-default database, user, password and port all took effect.docker inspectshowspg_isready --username=${POSTGRES_USER}expanded in-container.initdbtook 5.64s, healthy on the first probe.Decisions
Image pinned to major.minor and base (
18.4-trixie), checked against Docker Hub rather than training data. Debian rather than Alpine: musl collation differences are a poor rehearsal for a managed production Postgres.No restart policy. Removed in review. Once anyone runs
upwithout a laterdown, the repository would claim127.0.0.1:5432on every boot whether or not they are working on PlaceMark — the same port clashPOSTGRES_PORTexists to escape.on-failurewould be actively worse: a misconfigured volume path exits 1, so it would turn one visible dead container into a restart loop burying the error that explains it. The README says nothing auto-starts the container, and why.Weak default credentials and the loopback bind are one decision, not two.
placemark/placemarkis only defensible because nothing off the machine can reach it. The usual5432:5432would publish a database with a guessable password to every interface..env.exampledeliberately does not state the defaults. Every key is commented out with a non-default illustrative value, so the file cannot go stale when a default changes — drift-proof rather than merely deduplicated.cp .env.example .envverified to be a safe no-op.Healthcheck kept although nothing depends on it yet: it makes
docker compose up --waitblock until the server genuinely accepts connections, which is what verified this ticket.No ADR. ADR-0001 sets the bar at constraining the system's shape. This is local tooling. The one decision with real reach, no PostGIS, is already ADR-0006.
Traps documented, not fixed
Postgres reads
POSTGRES_USER/PASSWORD/DBonly when initialising an empty data directory. Editing.envafterwards silently does nothing — the container starts healthy on the old credentials. Now documented in both the README and.env.example; the first version of this PR claimed that and was wrong, which review caught.pg_isreadyis a liveness probe, not an auth probe. A healthy container is not credential validation.Notes for other tickets
appsettings*.jsontouched here.placemark-placemark-1,placemark-mongoand volumeplacemark_mongo-data, from an older mongo-based incarnation of this repo. Left untouched. Review confirmeddocker compose down -vwill not remove them (Compose removes only volumes declared in the file), but--remove-orphanswill remove the two containers..gitignorerule is redundant, confirmed: the Visual Studio template's*.envalready matches.envbecause a gitignore*matches the empty string, and.env.exampleis not matched. Kept anyway on the grounds that a file holding credentials deserves a rule stating its intent, in a vendored template that gets regenerated. Reviewer agreed it is defensible either way.Verdict: mergeable
Independent review. I re-ran every claim rather than reading them; Docker 29.7.1, Compose v5.3.1. All three acceptance criteria of Vikunja task #4 are met and verified. Nothing blocking. Five non-blocking items below, one of which I would like fixed before merge even though it changes no behaviour.
What I verified myself
The volume path — the central claim holds.
Persistence proven end to end (run under a throwaway project name so the machine's stale
placemarkproject was never touched):up -d --wait→ healthy in 5.64s from an empty volume includinginitdb→create table review_probe/insert 42→docker compose down(no-v) →up -d --wait→select id from review_probereturns42. The named volume resolves toplacemark_postgres-data, exactly as the README states.The failure mode is real, but it is not the failure mode you describe. I built a throwaway compose file identical except for
postgres-data:/var/lib/postgresql/data. It does not silently persist nothing and then discard the database ondown. It refuses to start at all, first boot, empty volume, exit code 1:So the mount path in this PR is right, and the reason it is right is sound — but the image guards this loudly, and the PR description's framing ("appearing to work perfectly until the first
docker compose downsilently discarded the database", "silently ineffective rather than loudly broken") is not what happens withpostgres:18.4-trixie. See the inline comment on the compose file.Loopback binding.
ss -ltngivesLISTEN 0 4096 127.0.0.1:5432 0.0.0.0:*. I also confirmed a TCP connect to this host's LAN address192.168.1.2:5432is refused. Claim holds, and this is the right call given the default password.Healthcheck escaping is correct — this was worth checking because
docker compose configre-escapes it and prints$${POSTGRES_USER}, which looks like a bug. The runtime value on the created container is not escaped:Compose passes it through and the container's shell expands it from the container environment. Correct as written.
start_period: 30s/interval: 10s/retries: 5is sensible and masks nothing. Measured cold start includinginitdbwas 5.64s and the container went healthy on the first probe withFailingStreak=0, sostart_periodnever delays--wait— a passing probe inside the start period marks healthy immediately. Its only effect is roughly 5x headroom before a slow start is called a failure, and worst-case time-to-unhealthy is 30 + 5x10 = 80s. Fine..envoverride, and the documented trap. WithPOSTGRES_DB=otherdb,POSTGRES_USER=otheruser,POSTGRES_PASSWORD=s3cret,POSTGRES_PORT=55434:ssshows127.0.0.1:55434, andpsql postgres://otheruser:s3cret@127.0.0.1:55434/otherdbreturnsotheruser|otherdb. The trap is exactly as documented — after changing the credentials in.envand recreating the container, the new credentials fail (FATAL: password authentication failed for user "changed"), the old ones still work, and the container reports healthy throughout. Yourpg_isready-is-not-an-auth-probe caveat is confirmed in the same run..gitignore. Both of your claims are true, and the explicit rule is genuinely redundant.I would keep it. The argument that a vendored, regeneratable template should not be the only thing standing between a credentials file and the index is a fair one, and the cost is one line. But it is dead config, so the comment above it should stay as honest as it currently is.
Judgements you asked for
POSTGRES_PORT— acceptable, keep it. It is one line, it is the same mechanism as the credentials, and a pre-existing Postgres on 5432 is the failure a newcomer will actually hit. Rejecting it on scope grounds would be pedantry.restart: unless-stopped— I would drop it. This is my main design disagreement. It is not merely "starts on every boot whether or not you are working on PlaceMark"; it means this repo permanently claims127.0.0.1:5432from boot on any machine that has ever runupwithout a subsequentdown. That is precisely the port-clash class of problemPOSTGRES_PORTexists to work around — the PR adds an escape hatch for other people's always-on Postgres while making PlaceMark's Postgres one of them. A development database should come up when asked and stay down otherwise;docker compose up -dis not onerous. If you want survival across a Docker daemon restart within a working session,restart: on-failuregets that without the boot behaviour. Non-blocking, but I think the ticket's own framing ("developers need not install Postgres by hand") argues for a database that behaves less like an installed service, not more.No ADR — agreed. ADR-0001's bar is not met by local tooling, and the decision with reach (no PostGIS) is already ADR-0006. Nothing here contradicts ADR-0006 or ADR-0007; the compose header pointing at ADR-0007 is a nice touch.
README sufficiency — yes. Someone who has never run this project gets the three commands, the volume name, the full default set, a connection string, both
psqlroutes, how to override, and how to destroy. That is more than most repos manage. British English is clean throughout the added prose.Non-blocking, in priority order
docker-compose.yml:11) — inline. This is the one I would like changed before merge..env.example, contrary to the PR description — inline. It is two lines and it is the file a developer will be looking at when they trip over it.restart: unless-stopped— inline, argument above.docker-compose.yml,.env.exampleand the README table. Nothing keeps them in step, and a drift between them is silent and confusing in exactly the way this PR is otherwise careful about. No fix needed now; worth knowing it is a maintenance edge.README.md:100now says the project requires Docker outright, butdotnet buildanddotnet testneed nothing of the sort today. It will become true when Testcontainers arrives. Trivial, mention only for accuracy.On the stale
placemarkproject, since you raised itI checked rather than assumed, because
down -vis in the README you just wrote.placemark-placemark-1,placemark-mongoand theplacemark_mongo-datavolume all carrycom.docker.compose.project=placemark, so this compose file adopts their project namespace. I simulated the exact shape in a throwaway project:docker compose down -vremoves only volumes declared in the compose file — the undeclared-but-labelled volume survived. So the README'sdown -vcannot destroyplacemark_mongo-data; the exposure is limited to--remove-orphansdeleting the two containers, which is what your description says. Your note is accurate and appropriately scoped.Everything I started is torn down: all four test projects
down -v, test volumes removed, no Postgres port listening. The three pre-existing artefacts are untouched and still exactly as I found them.@ -0,0 +9,4 @@## `.env` is gitignored. Never commit it, and never reuse these values anywhere but your machine.# Application configuration, including the connection string the API uses, is separate and goes# through `dotnet user-secrets` — not this file.The PR description says the init-only credentials trap is "documented in both the README and
.env.example". It is documented in the README only — this comment block covers what the file is for, that it is gitignored, and that application config goes through user-secrets, but not the trap.That matters because this is the file someone is looking at at the exact moment they trip over it: they edit
.env, restart, the container reports healthy, the old credentials still work and the new ones do not. I confirmed all three of those in a run just now.Two lines here would close it, e.g.:
@ -0,0 +8,4 @@# Pinned to a specific minor version and base image: an unannounced major bump would silently# change on-disk format, and `latest` makes "works on my machine" unfalsifiable. Postgres 18# keeps its data in $PGDATA=/var/lib/postgresql/18/docker, so the volume goes one level up at# /var/lib/postgresql — the pre-18 /var/lib/postgresql/data path persists nothing here.The mount path is right and I verified it, but this comment's stated reason is not what the image does.
"the pre-18 /var/lib/postgresql/data path persists nothing here" implies a container that runs and quietly loses data. I tested it:
postgres:18.4-trixierefuses to start at all, first boot, empty volume, exit 1 —Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster" ... there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume).This comment exists to stop a future developer "correcting" the path back to the tutorial value, and it will do that job better by telling them the truth: the image detects a mount at the legacy path and aborts, and the mount belongs one level up because $PGDATA is version-scoped at /var/lib/postgresql/18/docker so that
pg_upgrade --linkdoes not cross a mount boundary. As written, someone who hits the real error will not recognise it from this comment and may not trust the rest of the file.The same overstatement runs through the PR description ("appearing to work perfectly until the first
docker compose downsilently discarded the database") — worth correcting there too, since it is offered as the headline finding.@ -0,0 +10,4 @@# keeps its data in $PGDATA=/var/lib/postgresql/18/docker, so the volume goes one level up at# /var/lib/postgresql — the pre-18 /var/lib/postgresql/data path persists nothing here.image: postgres:18.4-trixierestart: unless-stoppedI would drop
restart: unless-stopped, or userestart: on-failure.unless-stoppedrestarts the container when the Docker daemon starts, so once anyone has rundocker compose upwithout a laterdown, this repo holds 127.0.0.1:5432 from boot, forever, on a machine that may not touch PlaceMark for weeks.That is the same problem
POSTGRES_PORTwas added to escape — you have given developers a workaround for other people's always-on Postgres while making this one of them. A development database is better as something that runs when asked.on-failurekeeps the useful part (recovery from a crash mid-session) without the boot behaviour.Not blocking; it is one word either way and reasonable people differ.
Verdict: mergeable
Re-review at
fcc4fde, superseding my verdict atfa13a0d. All five findings actioned; I re-ran the compose config, a full persistence cycle, and — because it is now asserted in a code comment — theup -dexit-code claim. Nothing blocking. Two optional nits at the bottom, neither worth another round on its own.Finding 1 — the new framing is accurate, not a hedge
I checked this specifically because "loud in the logs, invisible at the prompt" is the kind of sentence that sounds true and often is not. It is true, and all three halves hold. Fresh volume, mount moved to
/var/lib/postgresql/data, plaindocker compose up -d:Immediately afterwards,
docker compose psstill showedUp Less than a second (health: starting)with the port mapping listed. A moment laterps -agaveExited (1),.State.ExitCode=1, and theunused mount/volumeerror appeared only indocker compose logs. So there is a real window in which the prompt reports success, the exit code is 0, andpsshows a running container — and the only evidence is in the logs, which nobody reads on a successful-lookingup.That is a better description of the failure than either of ours. Mine ("loudly broken") understated how easy it is to miss; the original ("silently discarded the database") had the wrong mechanism. The comment now says the thing that is both true and useful to the person who is about to "correct" the path. Good.
Finding 3 — the exit-1 argument for dropping
on-failureis correctThe author's reasoning is sound and I had missed it. Given the container exits 1 rather than hanging,
restart: on-failurewould loop the entrypoint and flood the log with repeats, burying the singleunused mount/volumeblock that finding 1 exists to make findable. Removing the policy outright is the better call, and it is the same argument as finding 1 rather than a separate one — which is a point in its favour.Verified on the running container:
.HostConfig.RestartPolicy.Name = no. The README's replacement sentence ("Nothing starts the container for you, so runupagain after a reboot — deliberate, so that the repository does not hold port 5432 on days you are not using it") is accurate and states the trade-off rather than hiding it, which is better than silently dropping the key.Finding 4 — better than what I suggested, and I have changed my mind
I asked for deduplication; the author removed the duplicate instead. That is the stronger move and I would not go back to my version.
The distinction that matters: deduplication still leaves two artefacts that must agree, and correctness depends on someone remembering to update both. An
.env.examplethat never states the defaults cannot disagree with them, because it no longer makes a claim about them.# The values below are deliberately *not* the defaults, so that nothing here can quietly fall out of step with docker-compose.yml. They illustrate overriding; they are not a copy of what you get.is exactly right, and the illustrative values are well chosen —placemark_scratch,someone_else,choose-your-ownare self-evidently placeholders, so nobody uncomments all four by reflex and then wonders why the README's connection string does not work.I verified the no-op claim rather than taking it:
No warnings, all four defaults intact,
#KEY=valuewith no space parses as a comment. Uncommenting works:POSTGRES_PASSWORDbecamechoose-your-ownandpublishedbecame"55432".Is the example less useful to a newcomer? No. Nothing was lost, only moved, and the file says where it went ("those defaults and the connection details they produce are in the README"). A newcomer's actual question at that file is "how do I change the password", which it now answers better than before, because the old version's four default-valued lines invited a copy-and-change-nothing that did nothing.
Is the README's one sentence worse than the five-row table? Marginally different, not worse. The sentence plus the connection string carries every value the table did, and the connection string is the thing anyone actually copies. Dropping the local
psql "postgres://..."line in favour ofdocker compose execis fine — theexecform works with no host psql installed, and the connection string sits three lines above for anyone who wants to adapt it.Findings 2 and 5 — confirmed in the files, not taken on trust
The init-only trap is now in
.env.example:14-17in the strongest available form ("the container still starts, still reports healthy, and still holds the old name, username and password"), which matches what I measured last round exactly. The README keeps its own copy at lines 57-58; that duplication is appropriate, since the two files are read at different moments.README.md:100-101now scopes the prerequisite correctly, and the[database](#database)anchor resolves to the### Databaseheading.Re-verification of the unchanged parts
Since the compose file changed, I re-ran the lot rather than assuming:
docker compose configresolves to projectplacemark, volumeplacemark_postgres-data, mount/var/lib/postgresql, port127.0.0.1:5432; coldup -d --waitreaches healthy;insert 99→docker compose down(no-v) →up -d --wait→selectreturns99;ss -ltnshowsLISTEN 127.0.0.1:5432and nothing on0.0.0.0..gitignoreis untouched and my earliercheck-ignoreresults still stand.Optional nits — do not spin another round for these
docker-compose.yml:15-17— "the container never starts at all" sits a clause away from "reports the container as Started", which is a mild self-contradiction in a comment whose whole job is precision. It does start; it exits 1 within a second. "never comes up" or "exits 1 within a second" would remove the wobble. Genuinely trivial, and the surrounding sentences make the meaning unambiguous.docker-compose.yml:2— the header still says "copy .env.example to change them", which was accurate when the keys were live values. It is now copy and uncomment. One word.Both are a single-word edit if you happen to be touching the file anyway; neither justifies a further review cycle. Merge when ready.
All Docker state I created is gone — three test projects
down -v, scratch directories removed, no Postgres port listening.placemark-placemark-1,placemark-mongoandplacemark_mongo-dataare untouched and exactly as I found them. My worktree is still detached atfa13a0dwith a clean tree; I readfcc4fdeviagit show origin/feat/docker-compose:<path>and never checked it out.@ -0,0 +8,4 @@## cp .env.example .env## The values below are deliberately *not* the defaults, so that nothing here can quietly fall outI asked for deduplication; this is better, and I would not go back to my suggestion.
Deduplicating leaves two artefacts that must agree and depends on someone remembering both. A file that never states the defaults cannot fall out of step with them, because it makes no claim to fall out of step with. That is a stronger property than "currently consistent".
Verified the no-op:
cp .env.example .envthendocker compose configyieldsplacemark/placemark/placemarkon127.0.0.1:5432with no warnings —#KEY=valuewith no space parses as a comment. Uncommenting two keys took effect (choose-your-own, published"55432").The placeholder values are well chosen:
someone_elseandchoose-your-ownare self-evidently not defaults, so nobody uncomments the lot by reflex and then wonders why the README's connection string fails. And lines 3-5 point at the README for the real values, so nothing is lost — only moved, with a signpost.@ -0,0 +12,4 @@# mounts one level up at /var/lib/postgresql. Do not "correct" it to the pre-18# /var/lib/postgresql/data seen in most tutorials: the entrypoint spots the unused mount and# aborts on the first `up` with exit 1 and an error naming# "/var/lib/postgresql/data (unused mount/volume)". Loud rather than silent, but the containerVerified this whole paragraph rather than reading it, since the previous round turned on exactly this claim — and it is right.
Fresh volume, mount at the legacy path, plain
docker compose up -d: output endsContainer ... Startedand the shell exit code is 0.docker compose psrun immediately after still showedUp Less than a second (health: starting)with the port mapping.ps -aa moment later:Exited (1). Theunused mount/volumeerror appears only indocker compose logs.So "loud rather than silent, but the cause is not obvious from
docker compose up -d" is precisely accurate, and it is a better description than the one I offered in the last round. No change needed.One trivial wobble, take it or leave it: "the container never starts at all" sits one clause from "reports the container as Started". It does start — it runs the entrypoint and exits 1 within a second. "never comes up" would read cleanly. Not worth a round on its own.