# compose.yaml — the whole system locally, as in production (DIRECTION §3.3, # §3.6). Three services, each with a healthcheck so `docker compose up --wait` # blocks until the stack is genuinely ready: # # postgres accounts, the registry and sessions (§3.3) # mail Mailpit, standing in for the transactional email provider (§3.3) # service the one Go binary, git beside it and the built PWA, on one origin # # Host ports and Postgres credentials come from ESTANTE_* variables with # defaults, so `docker compose up` works unset for a developer running the # stack by hand (8080/5432/8025, estante/estante/estante), while scripts/gate # injects free random ports and a unique project name (-p) to run an isolated # stack beside that one. Only the mail HTTP API is published; SMTP (1025) stays # on the compose network, as only the service posts to it. name: estante services: postgres: image: postgres:17-alpine environment: POSTGRES_USER: ${ESTANTE_PG_USER:-estante} POSTGRES_PASSWORD: ${ESTANTE_PG_PASSWORD:-estante} POSTGRES_DB: ${ESTANTE_PG_DB:-estante} ports: # The DSN scripts/gate builds as ESTANTE_TEST_DATABASE_URL for the Go # behavior tests; bound to loopback only. - "127.0.0.1:${ESTANTE_PG_PORT:-5432}:5432" volumes: - postgres-data:/var/lib/postgresql/data healthcheck: # Healthy only when Postgres is accepting connections for this role and # database, not merely when the port is open. test: ["CMD-SHELL", "pg_isready -U ${ESTANTE_PG_USER:-estante} -d ${ESTANTE_PG_DB:-estante}"] interval: 3s timeout: 3s retries: 30 start_period: 10s mail: image: axllent/mailpit:latest ports: # The catcher's HTTP API (ESTANTE_E2E_MAIL_URL); SMTP (1025) is reachable # from the service over the compose network and is not published. - "127.0.0.1:${ESTANTE_MAIL_PORT:-8025}:8025" healthcheck: # The image is distroless (no shell); Mailpit's own binary answers its # readiness probe. test: ["CMD", "/mailpit", "readyz"] interval: 3s timeout: 3s retries: 20 start_period: 5s service: build: # The context is the repository root so the image build spans app/ and # service/ (see Dockerfile). context: . dockerfile: Dockerfile depends_on: # The one binary comes up only once its backing services are healthy, so # the whole stack is ready together under `up --wait`. postgres: condition: service_healthy mail: condition: service_healthy environment: # The registry's DSN (service.schema). On the compose network Postgres is # reachable at the service name on its internal 5432, not the published # host port; the binary opens a pgxpool from this and runs its start-up # migration against it. ESTANTE_* is the service's own namespace. ESTANTE_DATABASE_URL: postgres://${ESTANTE_PG_USER:-estante}:${ESTANTE_PG_PASSWORD:-estante}@postgres:5432/${ESTANTE_PG_DB:-estante}?sslmode=disable # The emailed sign-in link (service.email_links). The mailer hands the # link to Mailpit over SMTP on the compose network (mail:1025, unpublished); # the mailed link is built on the public origin, which is the stack's one # origin — scripts/gate authors it (ESTANTE_E2E_BASE_URL) and this reads it, # defaulting to http://localhost: for a bare `docker compose up`. The # signing secret is a dev-only placeholder here (never a real credential); # production injects a real ESTANTE_LINK_SECRET. Mailpit takes mail with no # SMTP AUTH, so ESTANTE_SMTP_USERNAME/ESTANTE_SMTP_PASSWORD stay unset here; # production sets both for the provider's relay (launch.email). ESTANTE_SMTP_ADDR: mail:1025 ESTANTE_MAIL_FROM: ${ESTANTE_MAIL_FROM:-no-reply@estantedecifras.app} ESTANTE_PUBLIC_BASE_URL: ${ESTANTE_E2E_BASE_URL:-http://localhost:${ESTANTE_HTTP_PORT:-8080}} ESTANTE_LINK_SECRET: ${ESTANTE_LINK_SECRET:-dev-insecure-link-secret} # Passkeys (service.passkeys). The relying-party ID is the effective domain # the passkey binds to; it is the stack's one-origin host, authored once by # scripts/gate (ESTANTE_WEBAUTHN_RP_ID) and read here, defaulting to # `localhost` for a bare `docker compose up`. It must be a named host, not # an IP literal — a real browser rejects WebAuthn on an IP-literal origin. # The e2e-api harness drives a software authenticator that echoes this RP ID # and stamps the same origin, so it validates too. The state secret is a # dev-only placeholder here (never a real credential); production injects a # real one. ESTANTE_WEBAUTHN_RP_ID: ${ESTANTE_WEBAUTHN_RP_ID:-localhost} ESTANTE_WEBAUTHN_STATE_SECRET: ${ESTANTE_WEBAUTHN_STATE_SECRET:-dev-insecure-webauthn-state-secret} # The key an invitation's token is derived with from its id, so the owner # can copy an open invitation's link again (books.share_people). A # dev-only placeholder here (never a real credential); production injects # a real ESTANTE_INVITATION_SECRET. ESTANTE_INVITATION_SECRET: ${ESTANTE_INVITATION_SECRET:-dev-insecure-invitation-secret} # The books volume's root (service.books): one bare git repository per book # at /.git. It is the mount point of the `books` volume below; # the default matches, so this is explicit wiring, not a new value. ESTANTE_BOOKS_ROOT: /data/books ports: # One origin: the published HTTP port serves both the app (at /, once # service.serve_app lands) and the API beneath it (ESTANTE_E2E_BASE_URL). # The binary binds its default internal 8080. - "127.0.0.1:${ESTANTE_HTTP_PORT:-8080}:8080" volumes: # One bare git repository per book under /data/books (DIRECTION §3.3), # the root ESTANTE_BOOKS_ROOT names; the Books API creates, forks and # removes repositories here (service.books). - books:/data/books healthcheck: # GET /healthz answers 200 "ok" with no I/O (service/internal/server); # busybox wget ships in the Alpine final image. test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"] interval: 3s timeout: 3s retries: 30 start_period: 5s volumes: postgres-data: books: