Deployment

Production compose

docker compose -f docker-compose.yml up -d

This is the same file used in Getting Started, without the dev-only docker-compose.override.yml hot-reload mounts. The frontend container serves the built React app via Nginx and proxies /api to the backend — it publishes no host port by default, since it’s meant to sit behind a reverse proxy.

Image tags

backend, celery-worker, celery-worker-process, celery-worker-podcast, celery-beat, and frontend all run pre-built images from Docker Hub (sebhoos/shoebill-backend, sebhoos/shoebill-frontend) rather than building locally. Set SHOEBILL_TAG in .env to choose which tag to run:

Tag

Tracks

latest (default)

The main branch — newest, but less tested

stable

The most recent tagged release — recommended for production

# .env
SHOEBILL_TAG=stable

Reverse proxy

Point a reverse proxy at the frontend container on port 80, terminating TLS there. With Traefik, for example, add labels to the frontend service:

frontend:
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.shoebill.rule=Host(`your-domain.example`)"
    - "traefik.http.routers.shoebill.entrypoints=web"
    - "traefik.http.routers.shoebill-secure.rule=Host(`your-domain.example`)"
    - "traefik.http.routers.shoebill-secure.entrypoints=web-secure"
    - "traefik.http.routers.shoebill-secure.tls=true"
    - "traefik.http.routers.shoebill-secure.tls.certresolver=letsencrypt"
  networks: [shoebill_internal, shoebill]  # shoebill = external network shared with Traefik

For local testing without any reverse proxy, add a direct port mapping instead:

frontend:
  ports:
    - "8080:80"

Volumes and persistence

Volume

Contents

postgres data volume

All application data

celerybeat-data

The Beat scheduler’s persisted schedule, so scheduled tasks don’t reset on restart

piper-voices

Downloaded Piper TTS voice models, cached after first use per language (only used with TTS_PROVIDER=piper)

podcast-audio

Generated podcast episode MP3s

Back up the Postgres volume; everything else is reproducible from config + migrations. docker-compose.tts.yml (a separate stack — see Configuration) has its own tts_voices volume for the same purpose as piper-voices when TTS_PROVIDER=network.

Note

Backend/Celery containers run as UID 1000, not root. If celerybeat-data already exists owned by root (e.g. from an older setup, or a manual docker volume create), the container will fail to write to it on startup — remove it and let Docker recreate it with the correct ownership: docker volume rm shoebill_feed_celerybeat-data.

Scaling notes

  • celery-worker (fetch + default queues) can run with higher concurrency safely — fetching is I/O-bound.

  • celery-worker-process (LLM processing) is deliberately concurrency 1 when backed by a single local Ollama instance/GPU — see Architecture for why. If you’re running Anthropic (or multiple Ollama instances behind a load balancer), raising this concurrency is reasonable.

  • Fetches for identical sources shared across users are automatically deduplicated (see Architecture), so adding more users doesn’t multiply outbound HTTP requests to the same feeds.

  • celery-worker-podcast runs on its own podcast queue specifically so episode generation (an LLM call plus CPU-bound TTS synthesis and ffmpeg encoding, potentially tens of seconds per episode) never delays celery-worker-process’s news pipeline.

  • TTS synthesis itself can be moved off celery-worker-podcast entirely — see “Running TTS synthesis on separate hardware” in Configuration for offloading it to a standalone, optionally GPU-backed container.

Health checks and auto-recovery

postgres, redis, and backend have always had Docker healthchecks. The four Celery services (celery-worker, celery-worker-process, celery-worker-podcast, celery-beat) do too. This matters because restart: unless-stopped alone only restarts a container when it actually exits — a worker that’s alive but stuck (e.g. it lost its Redis broker connection and never reconnected) keeps showing Up in docker ps forever, silently not processing anything, with nothing to notice or recover it.

The three worker services check in via Celery’s own control bus (celery inspect ping), which is answered by the worker’s control thread independently of whatever its pool child is currently executing — so the check stays fast even mid-task, including celery-worker-process sitting on a long-running Ollama call:

celery-worker-process:
  healthcheck:
    test: ["CMD-SHELL", "celery -A app.tasks.celery_app inspect ping -d celery@$$(hostname) --timeout 10 2>&1 | grep -q OK"]
    interval: 60s
    timeout: 15s
    retries: 3
    start_period: 30s

celery-beat has no equivalent control-ping RPC — there’s no consumer/task-execution machinery to answer one — so its healthcheck is weaker: a plain /proc scan confirming the process is still resident, not that it’s still ticking:

celery-beat:
  healthcheck:
    test: ["CMD", "python", "-c", "import os,sys; sys.exit(0 if any(b'beat' in open(f'/proc/{p}/cmdline','rb').read() for p in os.listdir('/proc') if p.isdigit()) else 1)"]
    interval: 60s
    timeout: 10s
    retries: 3
    start_period: 20s

Both checks avoid pgrep/ps deliberately — the backend image is python:3.12-slim with only libpq-dev gcc ffmpeg installed, no procps — so anything shelled out to in a healthcheck needs to be either celery itself or plain Python.

Upgrading

Migrations run automatically as part of the backend image’s startup. Pull the new images, then:

docker compose pull
docker compose up -d

If you’re running SHOEBILL_TAG=stable, this picks up whatever the most recent tagged release published; on latest, it picks up the newest build from main.

See Development for how migrations are written, if you’re running a fork with local schema changes (which requires building from source instead of using the published images).