Files
news/deploy/README.md
T
connor 83bc36aa79 Schedule poll/notify and the digest as systemd timers
news-cycle.timer runs poll then notify every 15 minutes; news-digest.timer runs
the briefing at 08:45 daily. Both enabled and verified: a manual cycle fetched
1047 items, embedded 95 new ones and clustered them in 74s.

Units are installed as COPIES rather than symlinks. The previous service's units
were symlinked into docs/projects/news/deploy/, and when that repo was renamed
to news_triage they became dangling and the timer failed every ten minutes,
unnoticed, until this rebuild found it. Copying costs a re-copy after editing;
symlinking cost days of silent failure.

Deliberate details: the poll ExecStart is `-` prefixed so a failed fetch does not
skip notify; RandomizedDelaySec spreads polling off the quarter hour rather than
hitting 56 sources at once; the digest is Persistent so a machine that was off
at 08:45 still sends rather than skipping the day.
2026-09-08 17:36:03 -04:00

7.7 KiB

Deploying

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

1. Compose — APPLIED 2026-09-08

The news-backend block in ~/compose.yml now reads (snapshot of the previous file: ~/compose.yml.pre-news-v2):

  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
      # NOT :ro -- the /config page writes back to profile.toml.
      - ./config/news2:/config
    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

The image is tagged localhost/news-py:latest rather than reusing git.rcjohnstone.com/connor/news:latest, so the Rust image stays on disk as the rollback path; the old database at ./data/news/db is likewise untouched. Rolling back is a two-line edit plus podman-compose up -d news-backend.

Config lives in ~/config/news2/, owned 10001:1000 and group-writable so the container (uid 10001) can save from the /config page while you can still edit the files by hand. :U would have worked for the container and locked you out.

The caddy service still bind-mounts ./data/news/web:/srv/news:ro, which is now unused since the Caddyfile no longer serves a static SPA. Harmless; remove it whenever the compose file is next touched. 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 — APPLIED 2026-09-08

Done. The block was moved out of the "Internal only" section into the Public section (the section headers are load-bearing documentation, and leaving a public block under an internal header would make the file lie), given a # ddns: public marker, and reloaded. ddns_update published the A record and Let's Encrypt issued a cert for the name.

Snapshot of the previous file: ~/Caddyfile.pre-news-public.

Verified after reload: / and /healthz return 403 (Authelia gating), while /api/feedback/... returns 404 -- a 404 from the OLD backend, which proves Caddy routed it through without authentication.

# 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 is now unused and the root-owned news-update systemd timer that populated it should be disabled:

sudo systemctl disable --now news-update.timer

Verified after the swap, from the public internet rather than the LAN:

request result meaning
GET / 403 (Authelia) browser paths are gated
POST /api/feedback/.../bogus {"error":"bad signature"} reaches the app, unauthenticated, and rejects a forgery
POST /api/feedback/.../<valid HMAC> {"ok":true} and a row in feedback the loop that never once worked in v0.2

3. Scheduling — APPLIED 2026-09-08

Two systemd timers, units in this directory:

unit cadence does
news-cycle.timer every 15 min poll then notify
news-digest.timer daily 08:45 digest
sudo cp deploy/news-*.service deploy/news-*.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now news-cycle.timer news-digest.timer

Copy the units, do not symlink them. The previous service's units were symlinks into docs/projects/news/deploy/, and when that repo was renamed to news_triage they became dangling and the timer failed every ten minutes, unnoticed, for days. The cost of copying is remembering to re-copy after editing a unit here; the cost of symlinking is a silent failure the next time a directory moves.

Design notes:

  • news-cycle.service has two ExecStart lines and the first is prefixed -, so a failed fetch does not skip notify -- there may still be ripe clusters from an earlier cycle worth sending.
  • RandomizedDelaySec=90 on the cycle: feeds get polled by everyone on the quarter hour, and this both spreads that load and avoids hitting all 56 sources at the same instant.
  • Persistent=true on the digest, so a machine that was off at 08:45 still sends the briefing on the next boot rather than silently skipping a day.
  • TimeoutStartSec=20min on the cycle: a cold start embeds the whole backlog at ~11 items/sec. Steady state is far quicker -- a real incremental cycle measured 74s for 95 new items.

Reading the logs is noisier than it should be, because podman writes its own container-exec events into the same unit journal:

journalctl -u news-cycle -o cat | grep -vE '^[0-9]{4}-[0-9]{2}-[0-9]{2} .* container '

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())"