Frontend Labs
⋯

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_on with conditions. service_healthy waits for the healthcheck, and service_completed_successfully waits for the migration job. Plain depends_on only waits for the container to start, which is the source of "connection refused on boot" flakiness.
  • Profiles: docker compose --profile obs up adds 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/goose image 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-lgtm is a dev-only image; pin a tag. Validate the file with docker 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 --wait blocks until every healthcheck passes, then you run the tests against it. It is the same stack developers run, so CI failures reproduce locally.


Generated from real-life-k8s-guide.md · 8 Oct 2026 16:36