Files
news/deploy/README.md
T
connor c9cbc3c7d2 Swap news-backend to the Python service
Applied to ~/compose.yml (snapshot: ~/compose.yml.pre-news-v2). The image is
tagged localhost/news-py:latest rather than reusing the registry tag, so the
Rust image stays on disk as the rollback path, and the old database at
./data/news/db is untouched -- rollback is a two-line edit.

Verified from the public internet, not the LAN:
  GET /                              -> 403, Authelia gating browser paths
  POST /api/feedback/../bogus        -> {"error":"bad signature"} from the APP,
                                        proving the carve-out bypasses Authelia
  POST /api/feedback/../<valid HMAC> -> {"ok":true}, and the row persists

That last line is the loop that never fired once in v0.2, and it is now proven
working from off-LAN with a signature check in front of it.
2026-09-08 16:48:16 -04:00

151 lines
5.9 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
- ./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
```
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/` (copied from `profile/*.toml`).
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
```
*/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())"
```