# Peblo TV Mini — one-command local stack. # # docker compose up --build # # No .env file is required: every interpolation below carries a working default, # so a fresh clone comes up seeded and browsable with zero manual steps. # Copy .env.example to .env only when you want to override something. # # API http://localhost:8000 (docs at /docs, health at /health) # CMS http://localhost:5173 admin@peblo.tv / admin123 # Viewer http://localhost:5174 (public, no login) # name: peblo-tv-mini services: # ---------------------------------------------------------------- database db: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-peblo} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-peblo} POSTGRES_DB: ${POSTGRES_DB:-peblo} ports: # Published only so you can psql into it while debugging. Change # POSTGRES_PORT in .env if you already run Postgres on 5432. - "${POSTGRES_PORT:-5432}:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: # The whole startup order hangs off this. `api` will not start its # migrations until pg_isready succeeds, which is what stops the classic # "connection refused on first boot, works on the second try" race. test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-peblo} -d ${POSTGRES_DB:-peblo}"] interval: 3s timeout: 3s retries: 20 start_period: 10s restart: unless-stopped # ------------------------------------------------------------------- API api: build: # Repo root, not ./api — api/Dockerfile COPYs data/ and assets/, which # live above api/. The seeder needs assets/ to attach real artwork, which # is what makes a fresh `up` render images instead of grey boxes. context: . dockerfile: api/Dockerfile depends_on: db: condition: service_healthy environment: DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://peblo:peblo@db:5432/peblo} JWT_SECRET: ${JWT_SECRET:-dev-only-secret-do-not-use-in-production} JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-12} # Storage abstraction: `local` writes under MEDIA_ROOT, `r2` talks to # Cloudflare R2 with the R2_* values. Swapping is one class, one env var. STORAGE_BACKEND: ${STORAGE_BACKEND:-local} # Single storage root: artwork and the immutable catalog/.json # snapshots both live under here. Backed by the `media` named volume. MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} # Artwork URLs are emitted relative (/media/...), so the browser resolves # them against VITE_API_BASE and there is no hostname baked into the DB. MEDIA_URL_PREFIX: ${MEDIA_URL_PREFIX:-/media} R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} R2_BUCKET: ${R2_BUCKET:-} R2_ENDPOINT: ${R2_ENDPOINT:-} R2_PUBLIC_BASE_URL: ${R2_PUBLIC_BASE_URL:-} # Where the app reads seed_shows.json / reference.json. The image COPYs # data/ in at build time; the read-only bind mount below just lets you # edit the seed on the host without rebuilding. DATA_DIR: ${DATA_DIR:-/app/data} SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@peblo.tv} SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-admin123} SEED_EDITOR_EMAIL: ${SEED_EDITOR_EMAIL:-editor@peblo.tv} SEED_EDITOR_PASSWORD: ${SEED_EDITOR_PASSWORD:-editor123} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:5174} LOG_LEVEL: ${LOG_LEVEL:-info} ports: - "${API_PORT:-8000}:8000" volumes: - ./data:/app/data:ro - media:/app/media # Migrate -> seed -> publish -> serve is api/entrypoint.sh, which is the # image's ENTRYPOINT. Deliberately NOT overridden here: the image has no # CMD, so a compose `command:` would be passed as *arguments to* the # entrypoint rather than replacing it, and land on the uvicorn line. # # No `healthcheck:` here either — api/Dockerfile declares one (curl against # GET /health, the contract's own operability endpoint), compose inherits it, # and cms/viewer below gate on it via `condition: service_healthy`. Two # copies of the same check is two things to keep in sync. The coupling is # worth knowing about: if that HEALTHCHECK is ever removed from the image, # `service_healthy` stops resolving and the UIs will not start. restart: unless-stopped # ------------------------------------------------------------------- CMS cms: build: context: ./cms args: # THE TRAP: Vite inlines import.meta.env.VITE_* at BUILD time. This is a # build ARG, not a runtime environment variable — setting it in # `environment:` would do precisely nothing. See agent-docs/40-pipeline.md. VITE_API_BASE: ${VITE_API_BASE:-http://localhost:8000} depends_on: api: condition: service_healthy ports: - "${CMS_PORT:-5173}:80" healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] interval: 10s timeout: 3s retries: 5 start_period: 5s restart: unless-stopped # ---------------------------------------------------------------- Viewer viewer: build: context: ./viewer args: VITE_API_BASE: ${VITE_API_BASE:-http://localhost:8000} depends_on: api: condition: service_healthy ports: - "${VIEWER_PORT:-5174}:80" healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] interval: 10s timeout: 3s retries: 5 start_period: 5s restart: unless-stopped volumes: pgdata: # Uploaded artwork plus the immutable catalog/.json snapshots. Named # volume, not a bind mount, so it survives `down` and is not polluted by host # file ownership. Mounted at /app/media, which api/Dockerfile creates and # chowns to uid 10001 before `USER peblo` — a volume at a path the image has # not prepared is created empty and root-owned, and the app cannot write to it. # `docker compose down -v` is the reset button. media: