7. Docker Compose: the whole system on your laptop
Compose is the right tool for local development and CI integration tests. It is not a production orchestrator for multi-host systems.
7.1 Orbit's compose.yaml
# compose.yaml
name: orbit
x-service-defaults: &svc
restart: unless-stopped
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
environment: &svc-env
OTEL_EXPORTER_OTLP_ENDPOINT: http://lgtm:4317
OTEL_RESOURCE_ATTRIBUTES: service.namespace=orbit,deployment.environment.name=local
DATABASE_URL: postgres://orbit:orbit@db:5432/orbit?sslmode=disable
REDIS_ADDR: redis:6379
depends_on: &svc-deps
db: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
services:
catalog:
<<: *svc
build: { context: ., args: { SERVICE: catalog } }
environment: { <<: *svc-env, OTEL_SERVICE_NAME: catalog }
ports: ["127.0.0.1:8081:8080"]
develop:
watch:
- { action: rebuild, path: ./internal }
- { action: rebuild, path: ./cmd/catalog }
orders:
<<: *svc
build: { context: ., args: { SERVICE: orders } }
environment: { <<: *svc-env, OTEL_SERVICE_NAME: orders }
ports: ["127.0.0.1:8082:8080"]
develop:
watch:
- { action: rebuild, path: ./internal }
- { action: rebuild, path: ./cmd/orders }
worker:
<<: *svc
build: { context: ., args: { SERVICE: worker } }
environment: { <<: *svc-env, OTEL_SERVICE_NAME: worker }
deploy: { replicas: 2 }
migrate:
image: ghcr.io/pressly/goose:latest # pin a version in real projects
command: ["-dir", "/migrations", "postgres", "postgres://orbit:orbit@db:5432/orbit?sslmode=disable", "up"]
volumes: ["./migrations:/migrations:ro"]
depends_on: { db: { condition: service_healthy } }
restart: "no"
db:
image: postgres:17
environment:
POSTGRES_USER: orbit
POSTGRES_PASSWORD: orbit
POSTGRES_DB: orbit
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orbit -d orbit"]
interval: 5s
timeout: 3s
retries: 10
ports: ["127.0.0.1:5432:5432"]
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes: [redisdata:/data]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
# Grafana + Prometheus + Loki + Tempo + OTel Collector in ONE container, for local dev only
lgtm:
image: grafana/otel-lgtm:latest
ports: ["127.0.0.1:3000:3000", "127.0.0.1:4317:4317", "127.0.0.1:4318:4318"]
profiles: [obs]
volumes:
pgdata:
redisdata:
What this demonstrates:
- YAML anchors (
x-extension fields +<<: *svc) keep the hardening flags DRY. depends_onwith conditions.service_healthywaits for the healthcheck, andservice_completed_successfullywaits for the migration job. Plaindepends_ononly waits for the container to start, which is the source of "connection refused on boot" flakiness.- Profiles:
docker compose --profile obs upadds the observability stack only when you want it. - Production hardening even locally (read-only, cap_drop) catches "app
writes to
/" bugs on day one instead of in the cluster.
⚠️ Caveat (verify): check that the
ghcr.io/pressly/gooseimage exists and pin a version. If it doesn't, build a two-line image (FROM golang+go install github.com/pressly/goose/v3/cmd/goose@<version>, copied into distroless) and use that.grafana/otel-lgtmis a dev-only image; pin a tag. Validate the file withdocker compose config.
7.2 Migrations
migrations/00001_init.sql (goose format):
-- +goose Up
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
stock INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE orders (
id UUID PRIMARY KEY,
idempotency_key TEXT UNIQUE NOT NULL,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | failed
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX orders_status_idx ON orders(status);
INSERT INTO products(name, price_cents, stock) VALUES
('Rocket', 9900, 100), ('Satellite', 49900, 20), ('Telescope', 19900, 50);
-- +goose Down
DROP TABLE orders;
DROP TABLE products;
7.3 Daily workflow
docker compose up -d --build # build + start everything
docker compose --profile obs up -d # include Grafana/LGTM at http://localhost:3000
docker compose watch # rebuild on file change (develop.watch)
docker compose ps # status + health
docker compose logs -f orders worker # tail selected services
docker compose exec db psql -U orbit # shell into the DB
docker compose up -d --scale worker=5 # scale a service
docker compose down # stop (keeps volumes)
docker compose down -v # stop AND delete volumes (fresh DB)
Best practice: Compose is also your integration test harness in CI.
docker compose up -d --waitblocks until every healthcheck passes, then you run the tests against it. It is the same stack developers run, so CI failures reproduce locally.