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.
189 lines
7.7 KiB
Markdown
189 lines
7.7 KiB
Markdown
# 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/.../<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` |
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
journalctl -u news-cycle -o cat | grep -vE '^[0-9]{4}-[0-9]{2}-[0-9]{2} .* container '
|
|
```
|
|
|
|
## 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())"
|
|
```
|