Files
news/deploy/README.md
T
connor a001a09fdf Add Dockerfile and deployment for replacing news-backend in place
Two-stage build. The builder bakes the embedding model into the image (~65 MB),
so a cold container needs no outbound access to Hugging Face and starts scoring
in 0.4s -- verified with HF_HUB_OFFLINE=1.

Three things the smoke test caught that would each have failed in production:

- Debian's base image already ships a system account named "news", so useradd
  exits 9 on the collision. The runtime user is "app".
- The image runs as uid 10001 rather than root, so a host-owned volume gives
  SQLite "attempt to write a readonly database". Compose needs the `U` mount
  flag.
- The baked model cache was root-owned, making fastembed log a permission
  warning and discard its tree cache on every start.

Deployment targets the EXISTING service rather than a parallel one: same vhost,
and NTFY_TOPIC=news-triage because the old service defaulted to that rather than
setting it, so the phone subscription keeps working.

The Caddy change is the consequential one and is documented as such: the vhost
moves from LAN-only to public, with /api/feedback/* bypassing Authelia because
ntfy runs action buttons from the phone and a native app cannot complete an
interactive login -- the same exemption ntfy, Immich and Jellyfin already have
on this box. That path is HMAC-signed per (cluster, kind).
2026-09-08 16:38:10 -04:00

4.4 KiB

Deploying

This replaces the news-backend service (from ../news_triage) in place: same host, same vhost, same ntfy topic.

1. Compose

Replace the news-backend block in ~/compose.yml:

  news-backend:
    image: git.rcjohnstone.com/connor/news:latest
    restart: unless-stopped
    networks:
      - internal
    environment:
      - TZ=America/Louisville
      - NEWS_DB=/data/news.db
      - NEWS_CONFIG_DIR=/config
      - NTFY_URL=${NTFY_URL}
      # The old service defaulted to this topic (bin_support.rs:121) rather than
      # setting it, so reusing it keeps the existing phone subscription working.
      - NTFY_TOPIC=news-triage
      - NTFY_USER=${NTFY_USER}
      - NTFY_PASS=${NTFY_PASS}
      # REQUIRED for feedback buttons. ntfy runs http actions FROM THE PHONE, so
      # this must be the public URL, not a container address. Its absence is
      # exactly why the previous version's buttons never fired.
      - NEWS_API_BASE_URL=https://news.rcjohnstone.com
      - NEWS_FEEDBACK_SECRET=${NEWS_FEEDBACK_SECRET}
      # One LLM call per day, for the digest prose only.
      - NEWS_LITELLM_KEY=${NEWS_LITELLM_KEY}
    volumes:
      # `U` makes podman chown the volume to the container's uid. The image runs
      # as uid 10001, not root, so without it SQLite fails with
      # "attempt to write a readonly database".
      - ./data/news2:/data:U
      - ./config/news2:/config:ro
    healthcheck:
      # NOT curl: the v0.2 image shipped none, so its healthcheck failed forever
      # while the service was fine and `podman ps` reported it unhealthy for days.
      test: ["CMD", "python", "-c",
             "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:3000/healthz',timeout=4).status==200 else 1)"]
      interval: 60s
      timeout: 5s
      retries: 3
      start_period: 20s

Copy profile/*.toml to ~/config/news2/. A bind-mounted config edit needs sudo podman restart news-backend; podman-compose up -d will not restart a container when only a mounted file changed. An environment change does need the full up -d.

2. Caddy

The existing news.rcjohnstone.com block serves a static SPA from /srv/news/dist and proxies only /api*. This app renders its own pages, so the block simplifies -- but it must also become reachable from the phone.

# ddns: public
news.rcjohnstone.com {
	import secure_headers
	import accesslog

	# ntfy executes a notification's action buttons FROM THE PHONE, and a native
	# app cannot complete an interactive login -- the same reason ntfy, Immich
	# and Jellyfin are exempted from the identity gateway on this box. So this
	# one path bypasses Authelia. It is safe to: every request carries an HMAC
	# signed over (cluster_id, kind), so a leaked URL casts one vote on one
	# story rather than acting as a bearer token for the model.
	@feedback path /api/feedback/*
	handle @feedback {
		reverse_proxy news-backend:3000
	}

	# Everything else is browser-facing, so it goes behind Authelia. Tapping the
	# digest notification opens a browser, where an interactive login works.
	handle {
		import authelia
		reverse_proxy news-backend:3000
	}
}

The change from the current block is deliberate and worth stating plainly: the vhost moves from LAN-only (tls internal + import internal, which aborts anything outside 10.0.0.0/24, 10.0.1.0/24, 10.89.0.0/16) to public. Without that, feedback buttons fail on cellular and the digest link is unopenable away from home -- and silent failure there is precisely how the previous version ended up with 32 labels in a week.

/srv/news/dist becomes unused and the news-update systemd timer that populated it can be disabled.

3. Scheduling

*/15 * * * *  podman exec news-backend python -m news.cli poll
*/20 * * * *  podman exec news-backend python -m news.cli notify
45   8 * * *  podman exec news-backend python -m news.cli digest

4. Verifying it actually works

podman exec news-backend python -m news.cli poll        # fetch, embed, cluster
podman exec news-backend python -m news.cli simulate    # what WOULD be sent
podman exec news-backend python -m news.cli notify --dry-run

Then the one test that matters, because it is the loop that was broken before: send a real notification, tap a feedback button on the phone, and confirm a row lands in feedback:

podman exec news-backend python -c \
  "import sqlite3;print(sqlite3.connect('/data/news.db').execute('select * from feedback').fetchall())"