sha256:85fc90312cbd121d4fa3b5f4732e6587ef86e8dc78b1d89354a2c6f38d2e5dcc
Last pushed
about 2 hours by tomborglum
Type
Compose
Manifest digest
sha256:85fc90312cbd121d4fa3b5f4732e6587ef86e8dc78b1d89354a2c6f38d2e5dcc
# The deployed stack: the three images on one network, plus two one-shot loaders behind a
# profile. This file is what a deployment IS - there is no other manifest, no chart and no
# deploy script - and the target is a single host, which is why every service names an
# `image:` and none a `build:`.
#
# That rule is not a style choice: `docker compose publish` REFUSES a service carrying
# `build` without `image`, and this file is meant to be published as an OCI artifact and
# loaded as `-f oci://...` on a machine holding no checkout. Nothing is built where this
# runs.
#
# The project name is `<repository>-<directory>`, the formula the published images and the
# published artifact already follow. So `tomborglum/expense-tracker-stack:2.2.0` tells a
# deployer the project, the network and the volume without opening this file, and two
# repositories cannot collide on one host. A release whose name does not match is failed.
# COMPOSE_PROJECT_NAME and `-p` outrank this key, and .env beside this file sets the
# first, which is what keeps a checkout off a release's volume.
#
# IMAGE_PREFIX and IMAGE_TAG carry a `:-` placeholder, not a `:?`, alone among the five.
# `publish` writes a second document into the artifact naming every image at its digest,
# and that document wins, so a release host reads these two and discards them: guarding
# them would demand a value nothing uses. The result is still interpolated per document,
# before the merge, so a bare `${...}` would warn ten times - once per service per name -
# on every command run against the artifact, which is what the defaults silence.
#
# The placeholder is deliberately not a real image name. A checkout whose .env lost them
# fails on the pull with `unset-image-prefix-...` in the message, rather than quietly
# running something that happens to exist. An `up` from a checkout takes them from .env
# beside this file.
#
# The other three ARE guarded, so an unset one stops `up`, `config` and `publish` alike
# with the variable's name in the message. Each `:?` names the file that owns it: .env
# beside this file for API_BASE_URL, backend/.env for the two host directories.
name: expense-tracker-stack
services:
# The schema travels in the image, applied by the base's entrypoint on an empty data
# directory. So this service needs no init script, no migration step and no ordering
# against anything: it is the first thing up and the only thing that writes.
database:
image: ${IMAGE_PREFIX:-unset-image-prefix}-database:${IMAGE_TAG:-unset-image-tag}
# The pair backend/.env's PGUSER and PGDATABASE have to agree with, which CLAUDE.md
# carries as a pair nothing checks. The entrypoint creates both on first boot, and
# POSTGRES_USER becomes the superuser, so an existing volume ignores a change here.
#
# trust auth on the compose network alone: nothing published holds a credential,
# `docker compose up` needs no arguments, and the backend reaches this server with the
# same four PG* names a developer's blessed shell carries - so config.py is untouched.
# It is the posture the dev cluster already runs, and it is safe for exactly the same
# reason: there is no route to this port from outside the network. The `ports:` key
# being ABSENT is what holds that true, and adding one would publish an unauthenticated
# superuser to the host.
#
# POSTGRES_INITDB_ARGS is deliberately not here. database/Dockerfile sets it as ENV so
# the locale cannot be lost by a caller who does not know to ask for it, and that image
# is its single source of truth.
environment:
POSTGRES_USER: expense_tracker
POSTGRES_DB: expense_tracker
POSTGRES_HOST_AUTH_METHOD: trust
# The path the base image actually declares as its volume. PostgreSQL 18 puts PGDATA at
# /var/lib/postgresql/18/docker UNDERNEATH it, so the pre-18 /var/lib/postgresql/data
# is the wrong mount point and would leave the real data directory inside the container
# layer. A named volume rather than a bind mount because the image runs as uid 70 with
# gosu removed: docker seeds a fresh named volume from the image's own directory,
# ownership included, where a host directory would need `chown 70:70` by hand first.
volumes:
- database-data:/var/lib/postgresql
# The -h is load-bearing and was measured, not assumed. Without it pg_isready uses the
# unix socket, which the entrypoint's init-phase temporary server DOES answer - observed
# reporting ready while schema.sql was still being applied, which starts the backend
# against a database that does not exist yet. That temporary server is socket-only, so
# over TCP readiness and init completing are the same moment.
#
# The user and database names are read from where they are set above rather than
# declared a second time in the image.
healthcheck:
test:
["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "expense_tracker", "-d", "expense_tracker"]
interval: 2s
timeout: 5s
retries: 30
# initdb plus the schema on a fresh volume, during which a failing probe is the
# normal state rather than a strike against the retry count.
start_period: 30s
restart: unless-stopped
# The REST API. The four PG* names are the ones config.py composes its DSN from, read
# straight out of the environment - it opens no file, so there is no .env in this image
# and no DATABASE_URL to set. PGHOST is the service name, which is the compose network's
# own DNS, and PGPORT is 5432 because inside the network there is nothing to collide
# with. Whichever port the local cluster listens on is a fact about a developer's host,
# not about this stack.
backend:
image: ${IMAGE_PREFIX:-unset-image-prefix}-backend:${IMAGE_TAG:-unset-image-tag}
environment:
PGHOST: database
PGPORT: 5432
PGUSER: expense_tracker
PGDATABASE: expense_tracker
# Published because the BROWSER calls this port, not the SPA container. The host port
# is the one API_BASE_URL names below, so the two move together: serving the API
# somewhere other than 8000 means setting API_BASE_URL as well.
ports:
- "8000:8000"
# service_healthy rather than service_started: the API answers 200 with an empty items
# array against an empty table, so a database that is up but still applying the schema
# would not fail loudly - it would serve plausible, wrong, empty answers.
depends_on:
database:
condition: service_healthy
# No healthcheck. The image is distroless and holds no shell, no curl and no wget, so
# there is nothing in it to probe itself with; adding one would mean adding a binary to
# a published image to serve compose.
restart: unless-stopped
# The SPA, served by unprivileged nginx on 8080 - a privileged port being out of reach of
# the uid it runs as.
frontend:
image: ${IMAGE_PREFIX:-unset-image-prefix}-frontend:${IMAGE_TAG:-unset-image-tag}
# The origin the BROWSER should call, which is why it cannot be http://backend:8000:
# the bundle runs on the host's browser, where that name does not resolve. The image's
# own entrypoint script rewrites the build-time placeholder across the document root
# with this value, refuses an absent one, and refuses one carrying a path - so a
# misconfiguration here stops the container rather than serving an app that reaches
# nothing. Wildcard CORS is what lets 8080 call 8000.
environment:
API_BASE_URL: ${API_BASE_URL:?stack/.env}
ports:
- "8080:8080"
# No depends_on. Nothing in this container talks to the API - the browser does - so an
# API that is not up yet is a failed fetch in a page that is already serving, not a
# startup ordering problem. Same reason there is no healthcheck: the substitution above
# is the only thing that could be wrong, and it exits non-zero on its own.
restart: unless-stopped
# The two loaders, over the API image, which already carries both modules. They are
# profiled so `docker compose up` brings up the three services above and nothing else,
# and `docker compose run --rm load-expenses` reaches one directly without `--profile`.
#
# `database` is NOT profiled, so these depends_on entries need no `required: false` -
# that is only for depending on a service a profile hides.
#
# The entrypoint is EXEC form and names the interpreter outright, for the same reason
# backend/Dockerfile's does: the image has no shell to expand a string form, and no
# `uvicorn`-style console script is involved. It overrides the image's own entrypoint,
# which would otherwise start uvicorn. One service per loader because of that - a shell
# is what it would take to run both in one container, and there is none.
#
# Both mounts are read-only, and both host directories must be readable by uid 65532.
# Both interpolations are required: a bare ${VAR} resolves to an EMPTY bind source and
# compose rejects that without saying which variable was missing, where `:?` names it.
#
# Both are also LONG syntax carrying `create_host_path: false`, which is the one setting
# here with data loss behind it. Compose creates a missing bind source by default, so an
# unset or mistyped directory would mount an empty one instead of failing - and the
# currency loader replaces the whole rate table from what it finds, so an empty directory
# deletes every rate and reports `0 files read` with exit 0. Refusing to create the path
# turns that into a container that does not start.
#
# It matters most where a checkout does not exist. A relative bind source in a file
# loaded from `oci://` resolves against compose's own download cache rather than the
# current directory - measured, not assumed - so both names below are set absolutely,
# in backend/.env from a blessed shell and by the operator anywhere else. README says
# so under Deployment.
load-currencies:
image: ${IMAGE_PREFIX:-unset-image-prefix}-backend:${IMAGE_TAG:-unset-image-tag}
profiles: ["load"]
entrypoint: ["/app/.pixi/envs/backend/bin/python", "-m", "expense_tracker.currency_loader"]
command: ["/currencies"]
environment:
PGHOST: database
PGPORT: 5432
PGUSER: expense_tracker
PGDATABASE: expense_tracker
# The rates live in this repository today and are mounted from it. The loader replaces
# the whole currency_rate table every run, a rate being a current fact rather than an
# event, so re-running this after editing rates.tsv is the supported path.
volumes:
- type: bind
source: ${CURRENCY_DATA_DIR:?backend/.env}
target: /currencies
read_only: true
bind:
create_host_path: false
depends_on:
database:
condition: service_healthy
# No restart policy. These two run once and exit 0, and `unless-stopped` would restart
# a finished loader forever.
load-expenses:
image: ${IMAGE_PREFIX:-unset-image-prefix}-backend:${IMAGE_TAG:-unset-image-tag}
profiles: ["load"]
entrypoint: ["/app/.pixi/envs/backend/bin/python", "-m", "expense_tracker.expense_loader"]
command: ["/expenses"]
environment:
PGHOST: database
PGPORT: 5432
PGUSER: expense_tracker
PGDATABASE: expense_tracker
# EXPENSE_DATA_DIR is the name backend/.env already carries, so a blessed shell points
# this at the private repository with no further setting. The default is the committed
# synthetic fixture set, which is what makes this path provable from a checkout alone -
# the same directory README names for filling a local database without that repository.
#
# The expense loader is append-only: its ledger skips a file whose sha256 matches and
# REFUSES one that changed, so a reclassified month is cured by dropping the volume and
# reloading, not by re-running this. See the reload sequence in README.
volumes:
- type: bind
source: ${EXPENSE_DATA_DIR:?backend/.env}
target: /expenses
read_only: true
bind:
create_host_path: false
depends_on:
database:
condition: service_healthy
# The one piece of state the stack keeps, and the thing `docker compose down -v` throws
# away. Dropping it is the wipe: schema.sql re-runs from the image's initdb.d on an empty
# data directory, so a reclassified expense directory is reloaded by down -v, up, then both
# loaders. No backend code exists for that and none is needed.
volumes:
database-data:
services:
backend:
image: docker.io/tomborglum/expense-tracker-backend:2.3.3@sha256:ff1e86d36f5350fd7788868357006af1cde36e6ea8441df9e30273c034f825d1
database:
image: docker.io/tomborglum/expense-tracker-database:2.3.3@sha256:c471546b0cd557a8cbfb657dde97791b24f2ae9434a85c26439d6fc863788d0b
frontend:
image: docker.io/tomborglum/expense-tracker-frontend:2.3.3@sha256:b57aee0e77ea5ba1f18ad89b6b6ec61ad0fa1d683a7064aca82281be2c4e5369
load-currencies:
image: docker.io/tomborglum/expense-tracker-backend:2.3.3@sha256:ff1e86d36f5350fd7788868357006af1cde36e6ea8441df9e30273c034f825d1
load-expenses:
image: docker.io/tomborglum/expense-tracker-backend:2.3.3@sha256:ff1e86d36f5350fd7788868357006af1cde36e6ea8441df9e30273c034f825d1
docker compose -f oci://tomborglum/expense-tracker-stack:2.3.3 upUse the above command to pull and run the Compose file. Learn more.