Sign inSign up

tomborglum/expense-tracker-stack:2.3.3

Manifest digest

sha256:85fc90312cbd121d4fa3b5f4732e6587ef86e8dc78b1d89354a2c6f38d2e5dcc

Last pushed

about 2 hours by tomborglum

Type

Compose

Manifest digest

sha256:85fc90312cbd121d4fa3b5f4732e6587ef86e8dc78b1d89354a2c6f38d2e5dcc

Compose file content

# 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 commands

docker compose -f oci://tomborglum/expense-tracker-stack:2.3.3 up

Use the above command to pull and run the Compose file. Learn more⁠.

Images used

Image


Pulls

698

Stars

0

Last Updated

about 2 hours

Image


Pulls

674

Stars

0

Last Updated

about 2 hours

Image


Pulls

584

Stars

0

Last Updated

about 2 hours