Tech Watch Automation Stack — Miniflux + n8n + digest workflow
This covers the full stack described in AGENTS.md (namespace watch,
shared PostgreSQL in namespace postgres, Miniflux with the initial feed
bootstrap, n8n for workflow orchestration, and the daily LLM digest
workflow) plus a small read-only file server for the generated digests.
Everything below is deployed and active on this cluster.
Layout
k8s/
namespace.yaml # namespace: watch (apps)
postgres/ # shared PostgreSQL instance (namespace: postgres)
namespace.yaml
secret.example.yaml # superuser credentials
statefulset.yaml
service.yaml
miniflux/ # namespace: watch (except create-db-job.yaml)
secret.example.yaml # DATABASE_URL, admin credentials
deployment.yaml
service.yaml
gateway.yaml # Gateway API (Envoy), HTTPS listener on 443
httproute.yaml # routes miniflux.sttlab.pc to the Service
create-db-job.yaml # runs in ns postgres: creates the "miniflux" database
bootstrap-feeds-job.yaml # runs miniflux-initializer to create categories/feeds
n8n/ # namespace: watch (except create-db-job.yaml)
secret.example.yaml # DB credentials, N8N_ENCRYPTION_KEY
miniflux-token-secret.example.yaml # Miniflux API token, backup/reference for the n8n credential
pvc.yaml # n8n-data (n8n's own /home/node/.n8n) + n8n-digest-workspace (/data, for Read/Write File nodes)
deployment.yaml
service.yaml
gateway.yaml # Gateway API (Envoy), HTTPS listener on 443
httproute.yaml # routes n8n.sttlab.pc to the Service
create-db-job.yaml # runs in ns postgres: creates the "n8n" database
digest-files/ # namespace: watch — read-only nginx file server
configmap.yaml # nginx.conf: autoindex on
deployment.yaml # mounts n8n-digest-workspace PVC (digests/ subdir), read-only
service.yaml
gateway.yaml # routes digests.sttlab.pc
httproute.yaml
miniflux-initializer/ # container image used by bootstrap-feeds-job.yaml
Dockerfile
bootstrap_feeds.py
feeds.yaml
requirements.txt
prompts/
digest.md # LLM system prompt for the digest workflow; mounted into n8n via
# the n8n-digest-prompt ConfigMap (regenerate after editing, see below)
workflows/
digest.json # "Tech Watch Digest" n8n workflow (daily 06:00 Europe/Paris cron)
Deploy
-
Namespaces
kubectl apply -f k8s/namespace.yaml kubectl apply -f k8s/postgres/namespace.yaml -
PostgreSQL —
k8s/postgres/{namespace,secret.example,statefulset,service}.yamldeploy a standalone instance and are kept for reference/portability, but on this cluster a shared PostgreSQL already runs in thepostgresnamespace (Bitnami Helm chart, releasepostgres, servicepostgres-postgresql). Reuse it instead of applying the StatefulSet:cp k8s/postgres/secret.example.yaml k8s/postgres/secret.yaml # edit k8s/postgres/secret.yaml: POSTGRES_PASSWORD must match the real # instance's password: # kubectl get secret postgres-postgresql -n postgres -o jsonpath='{.data.postgres-password}' | base64 -d kubectl apply -f k8s/postgres/secret.yaml kubectl apply -f k8s/miniflux/create-db-job.yaml kubectl wait --for=condition=complete job/create-db-miniflux -n postgres --timeout=60s(If deploying to a cluster with no existing shared Postgres, apply
statefulset.yaml/service.yamlfirst and updatecreate-db-job.yaml's host frompostgres-postgresqltopostgres.) -
TLS secret for the Gateway listeners (using the existing wildcard cert for
*.sttlab.pc)kubectl create secret tls sttlab-pc-tls \ --cert=~/tls/sttlab.pc.crt --key=~/tls/sttlab.pc.key \ -n watch -
Miniflux
cp k8s/miniflux/secret.example.yaml k8s/miniflux/secret.yaml # edit k8s/miniflux/secret.yaml: # - DATABASE_URL password must match k8s/postgres/secret.yaml # - set ADMIN_USERNAME / ADMIN_PASSWORD for the Miniflux admin account kubectl apply -f k8s/miniflux/secret.yaml kubectl apply -f k8s/miniflux/deployment.yaml kubectl apply -f k8s/miniflux/service.yaml kubectl apply -f k8s/miniflux/gateway.yaml kubectl apply -f k8s/miniflux/httproute.yamlExposed via Gateway API (Envoy Gateway,
gatewayClassName: envoy) on port 443 — this cluster'singress-nginxonly exposes ports 80/9443 externally, not 443, so a plainIngresswould not actually be reachable over HTTPS. Point DNS forminiflux.sttlab.pcat the Gateway address if not already done (kubectl get gateway miniflux -n watch).Feed
crawler(fetch original article content instead of the raw RSS/Atom excerpt) is enabled on all feeds except OpenAI's — its blog is behind a Cloudflare bot challenge that always rejects Miniflux's fetcher, so it stays on the RSS excerpt. Seecrawler:per feed inminiflux-initializer/feeds.yaml. -
Bootstrap feeds — runs as a one-off Job using a locally built image (no external registry needed on a single-node k3s cluster):
cd miniflux-initializer docker build --network=host -t miniflux-initializer:latest . docker save miniflux-initializer:latest | sudo k3s ctr images import - cd .. kubectl apply -f k8s/miniflux/bootstrap-feeds-job.yaml kubectl wait --for=condition=complete job/bootstrap-feeds -n watch --timeout=60s kubectl logs -n watch job/bootstrap-feedsSafe to re-run: delete the Job (
kubectl delete job bootstrap-feeds -n watch) and re-apply after editingminiflux-initializer/feeds.yaml(rebuild + re-import the image first if you changed it). Only adds new feeds — it does not updatecrawler/settings on feeds that already exist (use the Miniflux API or UI for that).Note:
--network=hostis required for thepip installstep during the image build — the local router does not answer DNS queries from Docker's bridge subnet, only the host's own resolver works. -
API token for n8n: create it manually in the Miniflux UI under Settings → API Keys — there is no public REST endpoint to create one. Kept as the source of truth in
k8s/n8n/miniflux-token-secret.yaml(see.examplefor the format); the n8n credential itself is created in step 8 below, from the same token. -
n8n
cp k8s/n8n/secret.example.yaml k8s/n8n/secret.yaml # edit k8s/n8n/secret.yaml: # - DB_POSTGRESDB_PASSWORD must match k8s/postgres/secret.yaml # - N8N_ENCRYPTION_KEY: generate once with `openssl rand -hex 24` and # never rotate it afterwards (it decrypts every saved credential) kubectl apply -f k8s/n8n/secret.yaml kubectl apply -f k8s/n8n/create-db-job.yaml kubectl wait --for=condition=complete job/create-db-n8n -n postgres --timeout=60s kubectl apply -f k8s/n8n/pvc.yaml # ConfigMap holding the digest LLM prompt (prompts/digest.md is the source # of truth; regenerate and re-apply this any time the file changes): kubectl create configmap n8n-digest-prompt --from-file=digest.md=prompts/digest.md \ -n watch --dry-run=client -o yaml | kubectl apply -f - kubectl apply -f k8s/n8n/deployment.yaml kubectl apply -f k8s/n8n/service.yaml kubectl apply -f k8s/n8n/gateway.yaml kubectl apply -f k8s/n8n/httproute.yamlSame Gateway API setup as Miniflux (TLS terminated by Envoy Gateway on 443, reusing the
sttlab-pc-tlssecret). Point DNS forn8n.sttlab.pcat the Gateway address once (kubectl get gateway n8n -n watch).On first login, n8n prompts to create the owner account (email/password) — there's no
CREATE_ADMIN-style env var like Miniflux, so this step is manual in the UI.Two PVCs are mounted:
n8n-datais n8n's own/home/node/.n8n(config, binary data cache);n8n-digest-workspaceis mounted separately at/datafor the digest workflow's Read/Write File nodes (prompt + generated digests). They're kept apart because n8n unconditionally blocks file-node access to its own/home/node/.n8ndirectory (N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES, not overridable viaN8N_RESTRICT_FILE_ACCESS_TO) — a dedicated volume avoids that restriction honestly instead of working around it. Note also thatN8N_RESTRICT_FILE_ACCESS_TOonly accepts a single path, not a list — hence bothprompts/anddigests/live under the same/dataroot. -
Digest workflow — import
workflows/digest.json(the "Tech Watch Digest" workflow: daily 06:00 Europe/Paris cron) and wire its credentials via the n8n Public API (Settings → n8n API → create a key first):N8N_KEY=<your n8n API key> # Miniflux credential (Header Auth, X-Auth-Token) — use the same token # as k8s/n8n/miniflux-token-secret.yaml curl -s https://n8n.sttlab.pc/api/v1/credentials -X POST \ -H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \ -d '{"name":"Miniflux API","type":"httpHeaderAuth","data":{"name":"X-Auth-Token","value":"<token>","allowedHttpRequestDomains":"all"}}' # LLM credential (Header Auth, Authorization: Bearer <key>) — OpenRouter or # any OpenAI-compatible provider curl -s https://n8n.sttlab.pc/api/v1/credentials -X POST \ -H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \ -d '{"name":"LLM API","type":"httpHeaderAuth","data":{"name":"Authorization","value":"Bearer <key>","allowedHttpRequestDomains":"all"}}' # Import the workflow, then edit the two credential IDs in its HTTP # Request nodes ("Get Unread Entries" / "Mark Entries As Read" -> Miniflux # API; "Summarize Category" -> LLM API) to match the IDs returned above, # and set the real llmBaseUrl / llmModel in the "Init" node's code. curl -s https://n8n.sttlab.pc/api/v1/workflows -X POST \ -H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \ --data-binary @<(python3 -c "import json; wf=json.load(open('workflows/digest.json')); print(json.dumps({k: wf[k] for k in ('name','nodes','connections','settings')}))") # Activate once credentials are wired and a manual run succeeds: curl -s https://n8n.sttlab.pc/api/v1/workflows/<id>/activate -X POST \ -H "X-N8N-API-KEY: $N8N_KEY"The workflow uses Miniflux's internal cluster Service DNS (
http://miniflux.watch.svc.cluster.local/v1/entries), not the external HTTPS hostname —miniflux.sttlab.pcisn't resolvable from inside the cluster (it only exists as a Gateway route, no DNS record).To test-run before activating (the running Deployment pod can't run
n8n executeitself — its Task Broker port is already bound — so use a throwaway Job with the same image/env/volumes instead), or just use the "Test workflow" button in the n8n UI from the Schedule Trigger node.Pipeline: fetch unread Miniflux entries from the last 24h → group by category → for each category, call the LLM (system prompt from
prompts/digest.md) to pick the 3-5 most relevant items + weak signals, strict JSON output → compose one Markdown file → write it to/data/digests/digest-<date>.md→ mark the processed entries as read in Miniflux. -
Digest file server — serves
/data/digests/(read-only) so digests can be browsed/downloaded from a browser instead of email:kubectl apply -f k8s/digest-files/Same Gateway/TLS pattern, routes
digests.sttlab.pc. Point DNS at the Gateway address (kubectl get gateway digest-files -n watch).
How to add a source
Add an entry under the relevant category (or a new category) in
miniflux-initializer/feeds.yaml, verify the URL responds with curl -I <url>, then rebuild the image and re-run the bootstrap Job (see step 5).
Validate manifests
kubectl apply --dry-run=client -f k8s/namespace.yaml
kubectl apply --dry-run=client -f k8s/postgres/
kubectl apply --dry-run=client -f k8s/miniflux/
kubectl apply --dry-run=client -f k8s/n8n/
kubectl apply --dry-run=client -f k8s/digest-files/
(Run against a cluster with the watch/postgres namespaces and secrets
already created, since some manifests reference them.)
Smoke-test checklist
kubectl get pods -n postgres— postgres podRunning,1/1readykubectl get pods -n watch— miniflux podRunning,1/1readykubectl logs -n watch deploy/minifluxshows migrations applied, no errorshttps://miniflux.sttlab.pc/healthcheckreturnsOK- Log in to the Miniflux UI with the admin credentials from the secret
kubectl logs -n watch job/bootstrap-feedsshows categories/feeds created- Categories and feeds appear in the Miniflux UI
- At least one feed shows fetched entries (Miniflux polls periodically; use "Refresh" in the UI to force it)
GET /v1/entries?status=unread(with an API token) returns entrieskubectl get pods -n watch— n8n podRunning,1/1readykubectl logs -n watch deploy/n8nshows no DB connection errorshttps://n8n.sttlab.pc/healthzreturns{"status":"ok"}- Log in to the n8n UI and create the owner account
- n8n can reach Miniflux: an HTTP Request node to
http://miniflux.watch.svc.cluster.local/v1/mewith the API token returns the admin user - "Tech Watch Digest" workflow imported, both credentials (Miniflux API, LLM API) attached to their HTTP Request nodes
- A manual run ("Test workflow" in the UI, or a throwaway
n8n execute --id=<id>Job) produces a file under/data/digests/in the n8n pod, and the corresponding Miniflux entries are marked read - Workflow activated (
"active": true) kubectl get pods -n watch— digest-files podRunning,1/1readyhttps://digests.sttlab.pc/lists the generated digest files
Current status
Deployed and active: Miniflux (9 feeds), n8n, "Tech Watch Digest" workflow
(daily 06:00 Europe/Paris, OpenRouter deepseek/deepseek-v4-flash), and
the digest file server at https://digests.sttlab.pc/. No email delivery
(SMTP not configured) — digests are Markdown files served over HTTP
instead.