There are two root Compose files:
| File | Purpose |
|---|---|
docker-compose.yml |
Deployment-style stack. Pulls application images from GHCR and publishes only the UI (3000) and backend (8000) to the host. |
docker-compose.dev.yml |
Local Docker development stack. Builds application images from this checkout and publishes dependency/service ports for hybrid development. |
-
Work from the repository root (the folder that contains
docker-compose.yml). -
Optional environment file. Copy
.env.exampleto.envto override secrets, CORS, public ports,GHCR_NAMESPACE, orIMAGE_TAG. Application images resolve asghcr.io/${GHCR_NAMESPACE}/experiment-tracker-<service>:${IMAGE_TAG}. Defaults areGHCR_NAMESPACE=malchulandIMAGE_TAG=0.12.1. -
storage/on disk. Data is persisted under./storage/(for examplestorage/postgres-backend,storage/clickhouse). You do not need to create these directories yourself: Docker creates missing host paths for bind mounts when the containers start. -
Pull GHCR images and start the deployment stack:
docker compose pull docker compose up -d
If the GHCR packages are private, run
docker login ghcr.iofirst.To build from the current checkout instead:
docker compose -f docker-compose.dev.yml up -d --build
-
Wait for health checks.
webstarts only afterbackendis healthy;backendwaits on Postgres, scalars, and object-storage. Watch status and logs:docker compose ps docker compose logs -f backend
Press Ctrl+C to stop tailing logs; containers keep running.
-
Open the UI. With default host ports, the Next.js app is:
http://localhost:3000 (equivalently http://127.0.0.1:3000)
The main API is on http://localhost:8000. The web container injects
PUBLIC_API_BASE_URLinto the frontend at runtime, so the same GHCR image can be used with different public API URLs.
The Build and publish Docker images GitHub Actions workflow runs only when manually started. Pushes, pull requests, and merges do not trigger it.
- Open Actions → Build and publish Docker images → Run workflow.
- Select the branch to build.
- Enter the additional image tag to publish, normally
latestor a release such asv1.2.0. - Run the workflow.
It publishes backend, scalars, object-storage, and web images under ghcr.io/<repository-owner>/experiment-tracker-*. Every image receives the selected tag and the full commit SHA. Use the SHA tag in IMAGE_TAG for an immutable deployment.
Use this when the UI or API is reached under a real hostname, HTTPS, or a non-default port on another machine (for example https://tracker.example.com for the app and https://api.example.com for the API).
From the repository root you can export everything from a single UI origin and start the stack (no root .env required). Simplest forms:
PUBLIC_URL=http://192.168.1.242 ./scripts/docker-up-public.shdocker-up-public.sh starts the deployment stack from docker-compose.yml. To build application images from the current checkout with docker-compose.dev.yml, use the same arguments with docker-up-dev.sh:
PUBLIC_URL=http://192.168.1.242 ./scripts/docker-up-dev.shIf the UI is on a non-default published port, set WEB_PORT (defaults to 3000). For http://… URLs without an explicit port, the script adds http://<host>:<WEB_PORT> to ALLOWED_ORIGINS as well as the bare URL, so the browser Origin from http://192.168.1.247:3000 matches after PUBLIC_URL=http://192.168.1.247. You can still set PUBLIC_URL=http://192.168.1.247:3000 explicitly if you prefer a single origin string.
./scripts/docker-up-public.sh https://dashboard.example.comBoth scripts set ALLOWED_ORIGINS, OBJECT_STORAGE_ALLOWED_ORIGINS, and runtime PUBLIC_API_BASE_URL, and keep SERVER_API_BASE_URL=http://backend:8000. docker-up-public.sh starts docker-compose.yml; docker-up-dev.sh builds and starts docker-compose.dev.yml.
- Different API host: pass a second URL:
./scripts/docker-up-public.sh https://dashboard.example.com https://api.example.com - Same as env var:
PUBLIC_URL=https://dashboard.example.com ./scripts/docker-up-public.sh - Only
PUBLIC_URL: the script is the supported “single variable” entrypoint; it fills in the other exports for Compose. - Different compose invocation: append
--and arguments, e.g../scripts/docker-up-public.sh http://myhost:3000 -- up -d
Override the in-container BFF target only if needed:
SERVER_API_BASE_URL=http://other:8000 PUBLIC_URL=... ./scripts/docker-up-public.sh
-
docker compose …and./scripts/docker-up-public.sh(it ends withdocker compose …): normally nosudoif your user can talk to the Docker daemon (Linux: user is in thedockergroup, or Docker Desktop on Mac/Windows). If you see permission denied on the Docker socket, you can run Compose withsudountil permissions are fixed (not ideal long-term). -
sudoandPUBLIC_URLfordocker-up-public.sh: assignments betweensudoand the program are passed into that command’s environment (not the same asPUBLIC_URL=…beforesudo, which applies only to your shell, not to root’s process). Typical pattern:sudo PUBLIC_URL=http://192.168.1.247 ./scripts/docker-up-public.sh sudo PUBLIC_URL=http://192.168.1.247 WEB_PORT=3000 ./scripts/docker-up-public.sh
Alternative: pass URLs as arguments so nothing depends on env (works even when assignment-style
sudois restricted bysudoers):sudo ./scripts/docker-up-public.sh http://192.168.1.247 sudo ./scripts/docker-up-public.sh http://192.168.1.247 http://192.168.1.247:8000
If you already exported
PUBLIC_URL/WEB_PORTin your shell and need root to see them, usesudo -E(preserve environment) or inline vars:sudo -E env PUBLIC_URL=… WEB_PORT=… ./scripts/docker-up-public.sh.-Eis asudoflag, not abashflag. If the script is not executable, usesudo PUBLIC_URL=… bash ./scripts/docker-up-public.sh.Running the script as root can create root-owned files under
./storage/; prefer adding your user to thedockergroup and running withoutsudo. -
rm -rf storage/: usually nosudoif files are owned by your user. If containers ran as root and created root-owned files under./storage, removal may fail until you runsudo rm -rf storage/once (then prefer running Docker with a user mapping or fix ownership withsudo chown -R "$USER:$USER" storage/if you want to avoid root-owned bind mounts). -
Installing Docker or changing groups is a one-time admin task and may require
sudoor an administrator account on your OS.
-
Configure root
.envnext todocker-compose.yml. Set at least:Variable Who consumes it What to set PUBLIC_API_BASE_URLWeb container at runtime Full base URL of the main API as the user’s browser calls it. The Next.js server injects it into the frontend. ALLOWED_ORIGINSBackend container Comma-separated origins of the UI exactly as the browser sends them in Origin(scheme + host + port). Example:https://tracker.example.com. Addhttp://localhost:3000too if you still use local dev against the same backend.OBJECT_STORAGE_ALLOWED_ORIGINSobject-storage container Same idea as ALLOWED_ORIGINS(browser talks to object-storage for some flows). Usually matchALLOWED_ORIGINS.SERVER_API_BASE_URLWeb container at runtime Leave the default http://backend:8000whenwebandbackendare both services in this Compose file. Only override if your Next server reaches the API by a different internal URL.PUBLIC_API_BASE_URLis intentionally browser-visible.SERVER_API_BASE_URLis used only by the remaining Next.js artifact proxy routes and can use private Compose DNS. -
Recreate
webafter changingPUBLIC_API_BASE_URL; no image rebuild is required:docker compose up -d --force-recreate web
-
Restart backend and object-storage after changing CORS variables (no rebuild required unless you changed code):
docker compose up -d --force-recreate backend object-storage
-
Reverse proxy / TLS in front of Compose: the browser must still be able to resolve
PUBLIC_API_BASE_URLto your API and the UI origin must appear inALLOWED_ORIGINS. Service-to-service URLs inside Compose (http://backend:8000,http://scalars:8001/api, etc.) stay on the Docker network and do not need to use your public domain.
Typical order when you want the stack gone and then a clean start next time:
-
Stop containers (keeps containers and volumes; fastest pause):
docker compose stop
-
Stop and remove containers and the Compose project network (usual teardown; data under
./storage/stays unless you delete it separately):docker compose down
Add
--remove-orphansif you changed service names and old containers remain. Add-vonly if you use named Docker volumes in this project and want them removed too (this compose file mainly uses bind mounts to./storage, so-voften does nothing for data persistence). -
Remove persisted data (optional, destructive; empty databases and blobs next
up):rm -rf storage/
-
Remove built images (optional; next
docker compose up --buildwill rebuild):docker compose down --rmi local -
Start again from Full stack: step by step.
| Path | Role |
|---|---|
docker-compose.yml |
Deployment stack using published application images |
docker-compose.dev.yml |
Development stack building application images from this checkout |
python/backend/Dockerfile |
Main API |
python/scalars_service/Dockerfile |
Scalars and ClickHouse API |
python/object_storage/Dockerfile |
Object storage API |
apps/web/Dockerfile |
Next.js standalone production image |
Python images declare HEALTHCHECK in their Dockerfiles so depends_on: service_healthy can gate startup.
The deployment stack publishes only web and backend ports. The development stack also publishes dependency and satellite-service ports:
| Host port | Service |
|---|---|
| 3000 | web |
| 8000 | backend |
| 8001 | scalars |
| 8002 | object-storage |
| 5435 | postgres (backend DB) |
| 5434 | postgres (object storage DB) |
| 6380 | redis |
| 8123 | ClickHouse HTTP |
| 9000 / 9001 | MinIO API / console |
Host ports are overridden with variables in a root .env (see .env.example). Container ports stay the same so services inside Compose keep talking to names such as redis:6379 and postgres-backend:5432.
If Compose fails with address already in use, create or edit root .env and set a free host port for the failing service:
REDIS_PORT=6381
POSTGRES_OBJECT_STORAGE_PORT=5436
POSTGRES_BACKEND_PORT=5437
MINIO_API_PORT=9010
MINIO_CONSOLE_PORT=9011Then restart:
docker compose down
docker compose up -dOnly the published host port changes. Containers continue using their unchanged internal service names and ports.
-
MinIO fails to start because host port 9000 is already in use. Set
MINIO_API_PORTandMINIO_CONSOLE_PORTto free ports in root.env, then restart the stack. -
Older Docker Engine and
minio/minio:latest. On some older installations, the current image may exit immediately. Pin theminioservice to a known-good release such asminio/minio:RELEASE.2024-11-07T00-52-20Z. -
Bad or incompatible local object-storage state. Stop the stack, clear persisted data, then start again. This is destructive:
docker compose down rm -rf storage/* docker compose up -dIf files were created as root, you may need
sudo rm -rf storage/*once, then fix Docker permissions.
Compose uses depends_on with health conditions. For example, backend waits for PostgreSQL, scalars, and object-storage, which wait on their own dependencies.
For a complete containerized stack:
docker compose up -d
docker compose logs -f backendTo run backend on the host while dependencies run in the development stack:
docker compose -f docker-compose.dev.yml up -d postgres-backend postgres-object-storage redis clickhouse minio minio-init scalars object-storage
cd python/backend
export DATABASE_URL="postgresql+asyncpg://tracker:tracker@127.0.0.1:5435/experiment_tracker"
export SCALARS_SERVICE_URL="http://127.0.0.1:8001/api"
export OBJECT_STORAGE_SERVICE_URL="http://127.0.0.1:8002/api"
uv run uvicorn api.main:app --reload --port 8000To run backend in Docker while PostgreSQL runs on the host, set DATABASE_URL to use host.docker.internal. On Linux, add this Compose override:
services:
backend:
extra_hosts:
- "host.docker.internal:host-gateway"Then start backend without its Compose PostgreSQL dependency:
docker compose up -d --no-deps backendTo run the development stack locally, you can use the following commands:
docker compose -f docker-compose.dev.yml up -d --buildThis will start the development stack with the dependencies running on the host.
Each Dockerfile uses paths from the repository root. Always build with context .:
docker build -f python/backend/Dockerfile -t experiment-tracker-backend .
docker build -f python/scalars_service/Dockerfile -t experiment-tracker-scalars .
docker build -f python/object_storage/Dockerfile -t experiment-tracker-object-storage .
docker build -f apps/web/Dockerfile -t experiment-tracker-web .Or build individual development-stack services:
docker compose -f docker-compose.dev.yml build backend
docker compose -f docker-compose.dev.yml build scalars
docker compose -f docker-compose.dev.yml build object-storage
docker compose -f docker-compose.dev.yml build webIf a service still misbehaves after edits, rebuild without cache and recreate it:
docker compose -f docker-compose.dev.yml build --no-cache backend web
docker compose -f docker-compose.dev.yml up -d --force-recreate backend webFor all application services:
docker compose -f docker-compose.dev.yml build --no-cache object-storage scalars backend web
docker compose -f docker-compose.dev.yml up -d --force-recreateIf problems persist, docker builder prune clears build cache for all Docker projects on the machine.