# news-digest — twice-daily LLM-curated briefing. # # Two containers in this stack, both built from the same Dockerfile: # # news-digest-worker — python + cron, runs digest.py at 0800/2000 # local, writes /output/index.html and # /output/edition-*.html. # news-digest-web — FastAPI on uvicorn, serves /output as static # and exposes /api/{hidden,hide,restore} for # the per-item × button (state in # /output/hidden.json, shared across devices). # Homepage card lives on this container. # # Same bind-mounted /output for both: worker writes HTML, web reads # it back. Worker uses atomic writes (.tmp + rename) so partial pages # never get served. services: news-digest-worker: image: local/news-digest:${NEWS_DIGEST_TAG:-v1} build: context: . dockerfile: Dockerfile # ${DIGEST_PROJECT} prefixes container names so multiple instances # (one per teammate, scoped to their miniflux user) can coexist on # the same host. Default keeps backward-compat with the original # singleton deploy. container_name: ${DIGEST_PROJECT:-news-digest}-worker restart: unless-stopped environment: - TZ=${NEWS_DIGEST_TZ:-America/Los_Angeles} - LLAMA_SWAP_URL=${LLAMA_SWAP_URL:-http://10.250.50.54:9292} - LLAMA_SWAP_MODEL=${LLAMA_SWAP_MODEL:-qwen3.5-35-a3b} - LLAMA_SWAP_TIMEOUT=${LLAMA_SWAP_TIMEOUT:-180} - MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080} - MINIFLUX_USER=${MINIFLUX_USER:-lkraven} - MINIFLUX_PASSWORD=${MINIFLUX_PASSWORD} - DIGEST_OUTPUT_DIR=/output - DIGEST_TEMPLATE_DIR=/app/templates - DIGEST_REDDIT_HOURS=${DIGEST_REDDIT_HOURS:-12} - DIGEST_MIN_SCORE=${DIGEST_MIN_SCORE:-50} - DIGEST_MIN_RATIO=${DIGEST_MIN_RATIO:-0.85} - DIGEST_MAX_PER_SUB=${DIGEST_MAX_PER_SUB:-8} - DIGEST_MINIFLUX_HOURS=${DIGEST_MINIFLUX_HOURS:-12} - DIGEST_MINIFLUX_MAX=${DIGEST_MINIFLUX_MAX:-8} - DIGEST_MINIFLUX_TECH_CATEGORY=${DIGEST_MINIFLUX_TECH_CATEGORY:-Tech aggregators} - DIGEST_MINIFLUX_WORLD_CATEGORY=${DIGEST_MINIFLUX_WORLD_CATEGORY:-World} - DIGEST_MINIFLUX_LOCAL_CATEGORY=${DIGEST_MINIFLUX_LOCAL_CATEGORY:-Local} - DIGEST_MINIFLUX_HEADLINES_HOURS=${DIGEST_MINIFLUX_HEADLINES_HOURS:-8} - DIGEST_MINIFLUX_HEADLINES_MAX=${DIGEST_MINIFLUX_HEADLINES_MAX:-15} # Per-instance cron schedule. Standard 5-field syntax. busybox # crond honors $TZ. Defaults preserve the original 0800 / 2000. - DIGEST_CRON_AM=${DIGEST_CRON_AM:-0 8 * * *} - DIGEST_CRON_PM=${DIGEST_CRON_PM:-0 20 * * *} volumes: - ${NEWS_DIGEST_OUTPUT_DIR}:/output networks: - tnet # Cron-driven worker — no healthcheck endpoint. The web container # is what users hit; if the worker dies we'll see stale content. # Restart policy handles transient crashes. news-digest-web: image: local/news-digest:${NEWS_DIGEST_TAG:-v1} build: context: . dockerfile: Dockerfile container_name: ${DIGEST_PROJECT:-news-digest}-web restart: unless-stopped depends_on: - news-digest-worker ports: - "${NEWS_DIGEST_BIND:-0.0.0.0}:${NEWS_DIGEST_PORT}:80" volumes: # Read-write here so the API can persist hidden.json. Worker also # writes here (HTML); both processes serialize via filenames they # don't share, plus uvicorn's threading.Lock around hidden.json. - ${NEWS_DIGEST_OUTPUT_DIR}:/output environment: - DIGEST_OUTPUT_DIR=/output # Override the worker's cron entrypoint to launch uvicorn instead. # tini still wraps the process for clean signal forwarding. entrypoint: ["/sbin/tini", "--"] command: ["uvicorn", "web:app", "--host", "0.0.0.0", "--port", "80", "--no-access-log"] healthcheck: # 127.0.0.1 instead of localhost — alpine's busybox wget tries # IPv6 first when localhost resolves to both ::1 and 127.0.0.1 # (per /etc/hosts). uvicorn only binds 0.0.0.0 (IPv4), so the # v6 attempt gets connection-refused and busybox doesn't fall # back. Pin to v4 explicitly. test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 30s networks: - tnet labels: - homepage.group=News - homepage.name=${DIGEST_HOMEPAGE_NAME:-Daily Digest} - homepage.icon=mdi-newspaper-variant-outline - homepage.description=${DIGEST_HOMEPAGE_DESC:-LLM-curated briefing across feeds, twice daily} - homepage.href=http://10.250.50.70:${NEWS_DIGEST_PORT} networks: tnet: name: traefik-net external: true