# 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`): ```yaml 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: ```sh 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/.../` | `{"ok":true}` and a row in `feedback` | **the loop that never once worked in v0.2** | ## 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 ```sh 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`: ```sh podman exec news-backend python -c \ "import sqlite3;print(sqlite3.connect('/data/news.db').execute('select * from feedback').fetchall())" ```