Skip to content

feat(docker): add production compose stack - #563

Merged
hamza-56 merged 4 commits into
mainfrom
feat/production-compose-stack
Jul 31, 2026
Merged

feat(docker): add production compose stack#563
hamza-56 merged 4 commits into
mainfrom
feat/production-compose-stack

Conversation

@hamza-56

@hamza-56 hamza-56 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes: Self-hosted production deployments need a production-grade Docker Compose stack

What

Adds a self-contained production Docker Compose stack that runs the whole platform (app, TimescaleDB with the app and analytics databases, Redis), plus a one-shot sparkth migrate CLI command that applies both Alembic lineages and the continuous-aggregate backfill, replacing the README's "convert the dev compose yourself" instructions.

Changes

  • feat(docker): add docker-compose.prod.yml running the app, TimescaleDB (app + analytics DBs), and Redis; every service loads .env and .env.local explicitly, and compose fails to start if .env.local is missing
  • feat(cli): add a migrate command that applies both Alembic lineages and backfills continuous aggregates in one step; make migrations now delegates to it
  • docs: rewrite the README production section around the new stack (plain docker compose commands, migrations run explicitly after up)
  • docs: note in .env, docker-compose.yml, and CLAUDE.md how the prod stack expects the connection URLs in .env.local and how the migrate command covers the backfill

How to Test

  1. make docker.build (or let compose pull ghcr.io/edly-io/sparkth:latest)
  2. Create .env.local with the production overrides, pointing DATABASE_URL, ANALYTICS_DATABASE_URL, and REDIS_URL at the bundled containers (see the README's "Production" section)
  3. docker compose -f docker-compose.prod.yml up -d
  4. docker compose -f docker-compose.prod.yml run --rm sparkth python -m sparkth.cli.main migrate shows both Alembic lineages applied and the aggregate backfill run
  5. docker compose -f docker-compose.prod.yml ps shows db and redis healthy and sparkth running
  6. Open http://localhost:7727 (bundled frontend is served) and http://localhost:7727/docs
  7. docker compose -f docker-compose.prod.yml down, then up -d again: data volumes survive
  8. Remove .env.local temporarily and re-run up -d: compose refuses to start
  9. uv run pytest tests/cli/test_migrate.py covers the new CLI command

Notes

  • New Alembic migrations: none. App code changes are limited to the new sparkth/cli/migrate.py command.
  • .env.local is now mandatory for the production stack and must carry the connection URLs (@db:5432, redis://redis:6379).
  • New compose-level knobs (interpolation-only, not app settings, so not added to .env): SPARKTH_TAG (image tag, default latest), SPARKTH_HTTP_PORT (published port, default 7727), SPARKTH_HTTP_BIND (bind address; set to 127.0.0.1 behind a same-host reverse proxy).
  • Migrations are not run automatically on startup; run the migrate command on first start and after every upgrade.
  • TLS and email are intentionally not part of the stack: run a reverse proxy in front (set TRUSTED_PROXY_HOPS) and point SMTP_* at a real provider in .env.local.

The README told deployers to hand-convert the dev compose file, which
predates the TimescaleDB analytics split and left migration ordering,
aggregate backfill, and env wiring as manual steps. docker-compose.prod.yml
now runs the full stack (app, TimescaleDB with the app and analytics
databases, Redis) with a one-shot job that applies both Alembic lineages
and backfills continuous aggregates before the app starts.

Connection URLs are built from POSTGRES_* at interpolation time, so the
make prod.* targets pass both .env and .env.local to compose. The app
service forces SERVE_FRONTEND=true because the env_file value would
otherwise override the image ENV and disable the bundled frontend.
@hamza-56 hamza-56 self-assigned this Jul 29, 2026
@hamza-56

Copy link
Copy Markdown
Contributor Author

@claude-review

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @hamza-56's task in 4m 51s —— View job


Review: production compose stack

  • Read the full diff (compose, Makefile, env, docs)
  • Review docker-compose.prod.yml for correctness & security
  • Review Makefile prod.* targets
  • Review .env / docker-compose.yml / docs changes
  • Post review feedback

This is a well-designed, self-contained stack — nice attention to detail throughout. Migrations gate app startup via service_completed_successfully, --wait surfaces failures, container hardening (no-new-privileges, init: true, non-root image, log rotation, shm_size, stop_grace_period) is all thoughtful, and the docker/initdb analytics-DB bootstrap + auto-backfill correctly matches the two-lineage migration model. The .env.local interpolation wiring and the docs updates are consistent. I verified analytics backfill-aggregates exists (sparkth/cli/analytics.py), the initdb script is present + executable, and the app image serves on :8000 with SERVE_FRONTEND correctly overridden over the .env default.

I left 3 inline comments. None are hard blockers, but the first is a real footgun in the PR's own test instructions.

Findings

🟠 Medium — make prod.logs migrations runs the local migrations target (Makefile:170)
ARGS comes from MAKECMDGOALS, and the extra goal is also executed as a target. migrations is a real target (Makefile:56), so make prod.logs migrations (PR How to Test step 5) tails the logs and then runs alembic upgrade head against your local DB. Suggest renaming the compose service migrationsmigrate.

🟡 Low/Medium — silent fallback to the committed dev password (docker-compose.prod.yml:30-31)
${POSTGRES_PASSWORD:-sparkth_password} lets a forgotten override boot with the well-known default. DB isn't exposed (good), but a ${VAR:?...} fail-fast fits a prod-only file better; same reasoning for the .env-sourced SECRET_KEY / LLM_ENCRYPTION_KEY.

🟡 Low — first-boot healthcheck race (docker-compose.prod.yml:95)
pg_isready can report ready over the unix socket during first-time initdb (socket-only phase), before sparkth_analytics exists / TCP is up, letting the migrations job start too early. start_period masks it most of the time; a retry loop in the migrations command or asserting the analytics DB in the healthcheck would harden it. Worth a cold-volume make prod.up to confirm.

Notes (non-blocking)

  • No automated tests — expected for a pure-infra change; the manual test plan in the PR body is solid. TimescaleDB persistence path (postgres_data:/var/lib/postgresql) mirrors the existing dev compose, so no new risk introduced here — just worth a quick down/up data-survival check on the timescaledb-ha:pg18 image.
  • depends_on conditions apply to compose up, not host-reboot restarts — the app's restart: unless-stopped will bring it back without re-gating on migrations, which is the desired behavior.

Overall: approve-worthy once the prod.logs migrations collision is resolved (rename or doc fix); the other two are optional hardening.
· feat/production-compose-stack

Comment thread Makefile Outdated

.PHONY: prod.logs
prod.logs: ## Tail production stack logs (make prod.logs [service])
$(COMPOSE_PROD) logs -f $(ARGS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] make prod.logs migrations collides with the real migrations target.

ARGS is derived from MAKECMDGOALS (line 5), and the extra goal is also handed to make as a target. For service names like db/redis that's harmless — the %: catch-all (line 277) swallows them. But migrations is a real target (line 56), and explicit targets win over the catch-all. So make prod.logs migrations — which the PR's own How to Test step 5 recommends — will:

  1. run prod.logs (tails logs -f, blocks until Ctrl-C), then
  2. run the migrations target → uv run alembic upgrade head against the local/native DB from .env (localhost:5432).

That's a surprising, potentially destructive side effect from what looks like a read-only "tail logs" command.

Simplest fix: rename the compose service migrationsmigrate (no target collision), then make prod.logs migrate is clean. Update the README/PR test steps to match.

Fix this →

Comment thread docker-compose.prod.yml Outdated
Comment on lines +30 to +31
DATABASE_URL: postgresql://${POSTGRES_USER:-sparkth}:${POSTGRES_PASSWORD:-sparkth_password}@db:5432/${POSTGRES_DB:-sparkth}
ANALYTICS_DATABASE_URL: postgresql://${POSTGRES_USER:-sparkth}:${POSTGRES_PASSWORD:-sparkth_password}@db:5432/sparkth_analytics

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low/Medium] Silent fallback to the committed dev password in production.

${POSTGRES_PASSWORD:-sparkth_password} means that if an operator forgets to set POSTGRES_PASSWORD in .env.local, the whole stack comes up with the well-known committed default rather than failing. The DB port isn't published in this stack (good — internal-only), so the blast radius is limited, but for a file whose entire purpose is production, defense-in-depth argues for failing fast:

DATABASE_URL: postgresql://${POSTGRES_USER:-sparkth}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env.local}@db:5432/${POSTGRES_DB:-sparkth}

Same reasoning applies to the app's SECRET_KEY / LLM_ENCRYPTION_KEY, which are loaded via env_file from the committed .env defaults — the "MUST change in production" checklist documents them but nothing enforces it. A fail-fast here turns a forgotten override into a startup error instead of a silently-insecure deployment.

Comment thread docker-compose.prod.yml
healthcheck:
# $$ defers expansion to the container shell, which always has
# POSTGRES_* from env_file (compose interpolation may not).
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-sparkth} -d $${POSTGRES_DB:-sparkth}"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] First-boot race: the healthcheck can go green before the analytics DB exists.

On a fresh volume the Postgres entrypoint runs docker/initdb/01-create-analytics-db.sh while the server listens on the unix socket only (TCP disabled). pg_isready here connects over that socket, so it can report accepting connections during the init phase — before sparkth_analytics has been created and before TCP is open. The migrations service depends only on db: service_healthy and immediately connects over TCP to db:5432 to run alembic -c alembic_analytics.ini upgrade head, which needs sparkth_analytics to already exist.

start_period: 30s masks this most of the time, but TimescaleDB first-init can be slow. Options to harden:

  • have the migrations command retry the connection (e.g. wrap in a short until pg_isready -h db ...; do sleep 1; done loop before running alembic), or
  • make the healthcheck assert the analytics DB exists, e.g. pg_isready on -d sparkth_analytics (only meaningful once created).

Not blocking, but worth confirming on a cold docker volume rm + make prod.up.

@regisb regisb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review pending. Please wait until I review this fully.

Comment thread docker-compose.prod.yml Outdated
# Both --env-file flags are required so the POSTGRES_* values from .env.local
# reach the interpolated connection URLs below.

name: sparkth-prod

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary, let's remove this line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Comment thread docker-compose.prod.yml Outdated

name: sparkth-prod

x-logging: &default-logging

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to override default logging. Let users decide that for themselves. We should remove this section and all "logging" settings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the logging section and all logging settings.

Comment thread docker-compose.prod.yml Outdated
max-file: "5"

# .env.local overrides .env (later entries win) and may be absent.
x-env-files: &env-files

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a big issue with how we handle environment files here. It really boils down to the fact that .env.local is mandatory. So we should explicitely load both .env and .env.local in all services:

env_file: 
  - .env
  - .env.local

No need to check for presence in Makefile: let's just crash if .env.local is not present.

Also, no need to create x-env-files: we can just duplicate the .env/.env.local list in all services.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, every service now lists .env and .env.local explicitly and compose crashes if .env.local is missing.

Comment thread docker-compose.prod.yml Outdated

# In-network URLs for the bundled services, overriding the localhost defaults
# committed in .env. POSTGRES_PASSWORD is embedded, so it must be URL-safe.
x-app-env: &app-env

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, why is this necessary? I suggest we add the list of explicit env variables to the sparkth service.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped the anchor, the connection URLs now go in .env.local and the sparkth service only sets SERVE_FRONTEND.

Comment thread docker-compose.prod.yml Outdated

# Applies both Alembic lineages, then backfills TimescaleDB continuous
# aggregates (idempotent). The app starts only after this succeeds.
migrations:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not run migrations automatically on every application start. Instead, add instructions in the README that explain how to run migrations. The fact that we need to run 3 different commands should tell us that we need to simplify the way we run migrations...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the auto-run job and collapsed the three commands into a single sparkth migrate CLI command, documented in the README.

Comment thread docker-compose.prod.yml
shm_size: 256mb
env_file: *env-files
volumes:
- postgres_data:/var/lib/postgresql

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally prefer bind-mounted data volumes, but let's stick with named volumes for consistency with the dev environment (nothing to change here).

Comment thread Makefile Outdated
# The prod compose file builds the app's connection URLs from POSTGRES_*, so
# compose interpolation must read .env.local (when present) on top of .env;
# these targets pass both --env-file flags for that reason.
COMPOSE_PROD = docker compose -f docker-compose.prod.yml --env-file .env$(if $(wildcard .env.local), --env-file .env.local)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Makefile is going to replicate all the docker compose commands (logs, up, down, etc.) but without all the nice CLI options. So I suggest we don't add make commands. Instead, the README instructs to run docker compose -f docker-compose.prod.yml up -d, then apply migrations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped the make targets, the README now uses plain docker compose commands.

hamza-56 added 2 commits July 29, 2026 21:32
Applying migrations took three commands (two alembic lineages plus the
aggregate backfill), which was easy to get wrong in production. The new
`sparkth migrate` command runs all three; `make migrations` now delegates
to it, so the backfill is no longer a separate manual step.
Address review feedback on the production stack:

- drop the project name override and all logging configuration, leaving
  log drivers to the operator
- make .env.local mandatory: every service loads .env and .env.local
  explicitly, and compose fails to start when .env.local is missing
- drop the x-env-files and x-app-env anchors; the connection URLs now
  live in .env.local (pointed at the bundled db and redis containers),
  and the sparkth service only overrides SERVE_FRONTEND
- stop running migrations automatically on startup; the README documents
  the single `sparkth migrate` command instead
- remove the make prod.* wrappers in favor of plain docker compose
  commands in the README
@hamza-56
hamza-56 requested a review from regisb July 29, 2026 16:37
@hamza-56

Copy link
Copy Markdown
Contributor Author

@claude-review

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @hamza-56's task in 1m 52s —— View job


Review: production compose stack (updated)

  • Read the updated diff (compose, CLI migrate, tests, docs)
  • Review docker-compose.prod.yml correctness & security
  • Review sparkth/cli/migrate.py + tests
  • Verify earlier review feedback was addressed
  • Post review

The stack was substantially simplified since the last round and reads much cleaner. The logging/anchor removals landed, env_file now lists .env + .env.local explicitly on every service, the Makefile prod targets are gone, and migrations are no longer auto-run. Nice work collapsing the three migration steps into sparkth migrate — the CLI correctly imports backfill_continuous_aggregates from sparkth.lib.analytics (not core), and it's covered by tests that assert both the ordering (app → analytics → backfill) and the non-Postgres skip path. TDD/lib-boundary/docstring conventions all look honored.

I couldn't run pytest/docker compose in this environment (sandbox denied), so the notes below are from reading the code.

Previous review comments — status

Reviewer request Status
Remove the unnecessary line ✅ resolved
Remove logging section + all logging settings ✅ resolved
Explicit .env + .env.local in all services; crash if .env.local missing; drop x-env-files ✅ resolved
Drop the extra env-var anchor; sparkth only sets SERVE_FRONTEND ✅ resolved
Don't auto-run migrations; document in README; simplify to fewer commands ✅ resolved (single migrate command)
Drop make prod.* targets; use plain docker compose in README ✅ resolved
Claude's earlier prod.logs migrations target collision ✅ moot (Makefile targets removed)

Findings (all non-blocking)

🟡 Low — first-boot race between migrate and analytics-DB creation (docker-compose.prod.yml:44-52, sparkth/cli/migrate.py:32)
On a cold volume, the Postgres entrypoint runs docker/initdb/01-create-analytics-db.sh while listening on the unix socket only, so pg_isready can report ready before sparkth_analytics exists / TCP is up. If an operator runs ... run --rm sparkth ... migrate immediately after up -d, alembic -c alembic_analytics.ini upgrade head can hit a "database does not exist"/connection-refused error. It's now a manual step (just re-run it), so severity dropped from the earlier round — but a short retry until pg_isready -h db loop inside the command, or a one-line README note ("wait for db to be healthy on first start"), would remove the sharp edge. Fix this →

🟡 Low — SECRET_KEY / LLM_ENCRYPTION_KEY still silently inherit committed dev defaults (docker-compose.prod.yml:16-18)
The DB password concern from last round is gone (URLs now live in .env.local), but SECRET_KEY and LLM_ENCRYPTION_KEY still flow in from the committed .env via env_file. If an operator forgets to override them in .env.local, the prod stack boots with well-known keys instead of failing. The "MUST change in production" checklist documents them, but nothing enforces it. Optional hardening: have the app fail fast at startup when these equal the shipped dev values. Given env_file ordering (.env.local wins) this is defense-in-depth, not a regression.

🟢 Nit — docstring vs. behavior in migrate_command (sparkth/cli/migrate.py:24)
The docstring says "Alembic config paths are resolved relative to the working directory, so run from the repository root (or /app inside the container)." Good caveat — worth mirroring that one line in the README's migrate step so operators running from a different CWD don't get a confusing "No config file 'alembic.ini' found" error. Not required.

Notes

  • command.upgrade(...) then asyncio.run(backfill_continuous_aggregates()) are sequential top-level calls (no nested event loop) — correct.
  • No bare except anywhere; a failing alembic upgrade will propagate and exit non-zero, which is the right behavior for a migration command.
  • pg18 / redis:7-alpine pins, no-new-privileges, init: true, shm_size, stop_grace_period, non-published DB/Redis ports, and SPARKTH_HTTP_BIND guidance are all sensible.

Overall: LGTM. All requested changes are addressed; the two remaining items are optional hardening, not blockers.
· feat/production-compose-stack

@regisb regisb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to go!

@hamza-56
hamza-56 merged commit 7947d31 into main Jul 31, 2026
6 checks passed
@hamza-56
hamza-56 deleted the feat/production-compose-stack branch July 31, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Self-hosted production deployments need a production-grade Docker Compose stack

2 participants