Miniflux feedreader — implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use dev-flow:subagent-driven-development (recommended) or dev-flow:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Design bead: hl-sy06
Spec: docs/engineering/specs/2026-06-12-miniflux-feedreader-design.md
Goal: Run Miniflux 2.3.1 as the cluster’s self-hosted RSS feedreader, backed by the shared CNPG main Postgres, authenticated via Authentik OIDC, with a native one-click Save into Karakeep.
Architecture: Standard GitOps app mirroring mealie — an ArgoCD Application syncing argocd/app-configs/miniflux/, a CNPG Database + role on the shared main cluster, a Terraform-provisioned Authentik OIDC client, and Vault-backed ExternalSecrets. Miniflux is stateless (all state in Postgres → no PVC). Deliberate “Save” actions push entries into Karakeep via Miniflux’s native integration.
Tech Stack: Kubernetes (k3s) · ArgoCD · Kustomize · CloudNativePG · External Secrets Operator + Vault · cert-manager · Traefik IngressRoute · Terraform (Authentik provider) · Miniflux 2.3.1.
Validation tooling (this repo): yamllint (.yamllint.yaml), kubectl kustomize (build), terraform fmt/validate. ArgoCD applies on merge — never kubectl apply directly.
Ordering note: the Authentik OIDC client (Task 3) writes oidc_client_id/oidc_client_secret into Vault fzymgc-house/cluster/miniflux, which the Miniflux app ExternalSecret (Task 5) consumes. Terraform (TFC main-cluster-authentik) runs on merge; if the Miniflux pod syncs before the creds land, it crash-loops until the 5-minute ExternalSecret refresh picks them up and Reloader restarts it. Acceptable, but Tasks 2–3 (CNPG + Authentik) MAY land in a first PR and Tasks 4–5 (app) in a second to avoid the race.
Task 1: Seed Vault secrets for Miniflux
Section titled “Task 1: Seed Vault secrets for Miniflux”Files: none (manual Vault writes — like the mealie/postgres user pattern).
- Step 1: Generate and store the Postgres role credentials
The CNPG role password is read from Vault by the users-miniflux ExternalSecret (Task 2), mirroring fzymgc-house/cluster/postgres/users/main-mealie.
Run:
vault kv put secret/fzymgc-house/cluster/postgres/users/main-miniflux \ username=miniflux \ password="$(openssl rand -base64 24)"- Step 2: Seed the Miniflux app secret with the break-glass admin
OIDC client id/secret are added later by Terraform (Task 3); seed only the admin creds now so the path exists.
Run:
vault kv put secret/fzymgc-house/cluster/miniflux \ admin_username=admin \ admin_password="$(openssl rand -base64 24)"- Step 3: Verify both paths
Run:
vault kv get -format=json secret/fzymgc-house/cluster/postgres/users/main-miniflux | jq '.data.data | keys'vault kv get -format=json secret/fzymgc-house/cluster/miniflux | jq '.data.data | keys'Expected: ["password","username"] and ["admin_password","admin_username"]. (ESO authenticates with fzymgc-cluster-secret-reader, which wildcards secret/data/* — no Vault policy edit.)
Task 2: CNPG database + role on the shared main cluster
Section titled “Task 2: CNPG database + role on the shared main cluster”Files:
- Create:
argocd/app-configs/cnpg/db-miniflux.yaml - Create:
argocd/app-configs/cnpg/users-miniflux.yaml - Modify:
argocd/app-configs/cnpg/postgres-cluster.yaml(add amanaged.rolesentry) - Modify:
argocd/app-configs/cnpg/kustomization.yaml(register the two new files) - Step 1: Create the Database CR (
db-miniflux.yaml) — mirrorsdb-mealie.yaml
---apiVersion: postgresql.cnpg.io/v1kind: Databasemetadata: name: miniflux namespace: postgresspec: cluster: name: main ensure: present name: miniflux owner: miniflux- Step 2: Create the role-credentials ExternalSecret (
users-miniflux.yaml) — mirrorsusers-mealie.yaml
---apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: main-miniflux-credentials namespace: postgresspec: refreshPolicy: Periodic refreshInterval: 5m secretStoreRef: name: vault kind: ClusterSecretStore target: name: main-miniflux-credentials creationPolicy: Owner deletionPolicy: Delete template: metadata: labels: cnpg.io/reload: "true" type: kubernetes.io/basic-auth data: username: "{{ .username }}" password: "{{ .password }}" data: - secretKey: username remoteRef: key: fzymgc-house/cluster/postgres/users/main-miniflux property: username - secretKey: password remoteRef: key: fzymgc-house/cluster/postgres/users/main-miniflux property: password- Step 3: Add the managed role to
postgres-cluster.yamlunderspec.managed.roles, after the last existing role (octopus) (match the existing block exactly):
- name: miniflux ensure: present passwordSecret: name: main-miniflux-credentials superuser: false login: true connectionLimit: -1 inherit: true- Step 4: Register both files in
argocd/app-configs/cnpg/kustomization.yamlresources:(after theusers-octopus.yamlline):
- db-miniflux.yaml - users-miniflux.yaml- Step 5: Validate
Run:
yamllint argocd/app-configs/cnpg/db-miniflux.yaml argocd/app-configs/cnpg/users-miniflux.yaml argocd/app-configs/cnpg/postgres-cluster.yaml argocd/app-configs/cnpg/kustomization.yamlkubectl kustomize argocd/app-configs/cnpg >/dev/null && echo "kustomize OK"Expected: no yamllint errors; kustomize OK.
- Step 6: Commit
jj --no-pager commit -m "feat(cnpg): add miniflux database + role on shared main [hl-sy06]"jj commit describes @ and opens a fresh change on top, so each task stacks as its own change on feat/miniflux-feedreader. The bookmark is advanced once at push time (Task 6).
Task 3: Authentik OIDC provider + application (Terraform)
Section titled “Task 3: Authentik OIDC provider + application (Terraform)”Files:
-
Create:
tf/authentik/miniflux.tf -
Step 1: Create
tf/authentik/miniflux.tf— mirrorskarakeep.tf, but stores the values Miniflux needs (base-path discovery URL, client id/secret) and creates a fresh Vault secret (merge with existing, since Task 1 seeded admin creds there):
# Miniflux OAuth2 Provider and Application## Integrates the Miniflux RSS reader with Authentik for SSO.# Miniflux's generic OIDC callback is /oauth2/oidc/callback.
resource "authentik_provider_oauth2" "miniflux" { name = "Miniflux" client_type = "confidential" client_id = "miniflux"
authorization_flow = data.authentik_flow.default_provider_authorization_explicit_consent.id invalidation_flow = data.authentik_flow.default_provider_invalidation_flow.id
allowed_redirect_uris = [ { matching_mode = "strict" url = "https://miniflux.fzymgc.house/oauth2/oidc/callback" } ]
property_mappings = [ data.authentik_property_mapping_provider_scope.email.id, data.authentik_property_mapping_provider_scope.openid.id, data.authentik_property_mapping_provider_scope.profile.id ]
signing_key = data.authentik_certificate_key_pair.tls.id}
resource "authentik_application" "miniflux" { name = "Miniflux" slug = "miniflux" protocol_provider = authentik_provider_oauth2.miniflux.id meta_launch_url = "https://miniflux.fzymgc.house" meta_description = "Self-hosted RSS feedreader" meta_publisher = "miniflux"
lifecycle { ignore_changes = [ meta_icon ] }}
# Merge OIDC creds into the existing Vault secret seeded in Task 1.data "vault_kv_secret_v2" "miniflux_existing" { mount = "secret" name = "fzymgc-house/cluster/miniflux"}
resource "vault_kv_secret_v2" "miniflux" { mount = "secret" name = "fzymgc-house/cluster/miniflux"
data_json = jsonencode(merge( data.vault_kv_secret_v2.miniflux_existing.data, { oidc_client_id = authentik_provider_oauth2.miniflux.client_id oidc_client_secret = authentik_provider_oauth2.miniflux.client_secret } ))
custom_metadata { max_versions = 5 data = { managed_by = "terraform" application = "miniflux" } }}- Step 2: Validate
Run:
cd tf/authentik && terraform fmt -check miniflux.tf && terraform validate && cd -Expected: fmt clean; Success! The configuration is valid. (Requires terraform init already done in the module; TFC main-cluster-authentik applies on merge — do NOT terraform apply locally.)
- Step 3: Commit
jj --no-pager commit -m "feat(authentik): OIDC provider + application for miniflux [hl-sy06]"Task 4: Miniflux app manifests
Section titled “Task 4: Miniflux app manifests”Files:
- Create:
argocd/app-configs/miniflux/namespace.yaml - Create:
argocd/app-configs/miniflux/secrets.yaml - Create:
argocd/app-configs/miniflux/deployment.yaml - Create:
argocd/app-configs/miniflux/service.yaml - Create:
argocd/app-configs/miniflux/certificate.yaml - Create:
argocd/app-configs/miniflux/ingress.yaml - Create:
argocd/app-configs/miniflux/kustomization.yaml - Step 1:
namespace.yaml
---apiVersion: v1kind: Namespacemetadata: name: miniflux- Step 2:
secrets.yaml— two ExternalSecrets (DB url + app creds), mirroringmealie/secrets.yaml
---apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: miniflux-db-secrets namespace: minifluxspec: refreshPolicy: Periodic refreshInterval: 5m secretStoreRef: name: vault kind: ClusterSecretStore target: name: miniflux-db-secrets creationPolicy: Owner deletionPolicy: Delete template: type: Opaque data: db-url: "postgresql://{{ .postgres_user }}:{{ .postgres_password }}@main-rw.postgres.svc.cluster.local:5432/miniflux?sslmode=disable" data: - secretKey: postgres_user remoteRef: key: fzymgc-house/cluster/postgres/users/main-miniflux property: username - secretKey: postgres_password remoteRef: key: fzymgc-house/cluster/postgres/users/main-miniflux property: password---apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: miniflux-app-secrets namespace: minifluxspec: refreshPolicy: Periodic refreshInterval: 5m secretStoreRef: name: vault kind: ClusterSecretStore target: name: miniflux-app-secrets creationPolicy: Owner deletionPolicy: Delete template: type: Opaque data: admin-username: "{{ .admin_username }}" admin-password: "{{ .admin_password }}" oidc-client-id: "{{ .oidc_client_id }}" oidc-client-secret: "{{ .oidc_client_secret }}" data: - secretKey: admin_username remoteRef: key: fzymgc-house/cluster/miniflux property: admin_username - secretKey: admin_password remoteRef: key: fzymgc-house/cluster/miniflux property: admin_password - secretKey: oidc_client_id remoteRef: key: fzymgc-house/cluster/miniflux property: oidc_client_id - secretKey: oidc_client_secret remoteRef: key: fzymgc-house/cluster/miniflux property: oidc_client_secret- Step 3:
deployment.yaml— stateless (no PVC), Reloader-managed, full env
---apiVersion: apps/v1kind: Deploymentmetadata: name: miniflux namespace: miniflux labels: app.kubernetes.io/name: miniflux annotations: reloader.stakater.com/auto: "true"spec: replicas: 1 strategy: type: RollingUpdate selector: matchLabels: app.kubernetes.io/name: miniflux template: metadata: labels: app.kubernetes.io/name: miniflux spec: containers: - name: miniflux image: miniflux/miniflux:2.3.1 imagePullPolicy: IfNotPresent ports: - containerPort: 8080 name: http protocol: TCP env: - name: DATABASE_URL valueFrom: secretKeyRef: name: miniflux-db-secrets key: db-url - name: RUN_MIGRATIONS value: "1" - name: CREATE_ADMIN value: "1" - name: ADMIN_USERNAME valueFrom: secretKeyRef: name: miniflux-app-secrets key: admin-username - name: ADMIN_PASSWORD valueFrom: secretKeyRef: name: miniflux-app-secrets key: admin-password - name: BASE_URL value: "https://miniflux.fzymgc.house" - name: LISTEN_ADDR value: "0.0.0.0:8080" - name: TZ value: "America/Los_Angeles" # Authentik OIDC. Discovery endpoint is the slug BASE path with a # trailing slash — Miniflux appends /.well-known/openid-configuration # itself (do NOT use the full well-known URL). - name: OAUTH2_PROVIDER value: "oidc" - name: OAUTH2_OIDC_PROVIDER_NAME value: "Authentik" - name: OAUTH2_OIDC_DISCOVERY_ENDPOINT value: "https://auth.fzymgc.house/application/o/miniflux/" - name: OAUTH2_REDIRECT_URL value: "https://miniflux.fzymgc.house/oauth2/oidc/callback" - name: OAUTH2_USER_CREATION value: "1" - name: OAUTH2_CLIENT_ID valueFrom: secretKeyRef: name: miniflux-app-secrets key: oidc-client-id - name: OAUTH2_CLIENT_SECRET valueFrom: secretKeyRef: name: miniflux-app-secrets key: oidc-client-secret resources: requests: memory: "128Mi" cpu: "100m" limits: memory: "512Mi" cpu: "1000m" livenessProbe: httpGet: path: /healthcheck port: http initialDelaySeconds: 15 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /healthcheck port: http initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 restartPolicy: Always- Step 4:
service.yaml
---apiVersion: v1kind: Servicemetadata: name: miniflux namespace: minifluxspec: type: ClusterIP selector: app.kubernetes.io/name: miniflux ports: - protocol: TCP port: 8080 targetPort: http name: http- Step 5:
certificate.yaml— mirrorsmealie/certificate.yaml
---apiVersion: cert-manager.io/v1kind: Certificatemetadata: name: miniflux-tls namespace: minifluxspec: secretName: miniflux-tls issuerRef: name: vault-issuer kind: ClusterIssuer dnsNames: - miniflux.fzymgc.house - miniflux.k8s.fzymgc.house - miniflux.miniflux.svc.cluster.local - miniflux.miniflux.svc - miniflux usages: - server auth- Step 6:
ingress.yaml— Traefik IngressRoute, mirrorsmealie/ingress.yaml
---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: miniflux namespace: miniflux annotations: router-hosts.fzymgc.house/enabled: "true"spec: entryPoints: - websecure routes: - match: Host(`miniflux.fzymgc.house`) kind: Rule services: - name: miniflux port: 8080 - match: Host(`miniflux.k8s.fzymgc.house`) kind: Rule services: - name: miniflux port: 8080 tls: secretName: miniflux-tls- Step 7:
kustomization.yaml
---apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization
namespace: miniflux
resources: - namespace.yaml - secrets.yaml - deployment.yaml - service.yaml - certificate.yaml - ingress.yaml- Step 8: Validate
Run:
yamllint argocd/app-configs/miniflux/kubectl kustomize argocd/app-configs/miniflux >/dev/null && echo "kustomize OK"Expected: no yamllint errors; kustomize OK.
- Step 9: Commit
jj --no-pager commit -m "feat(miniflux): app manifests (deployment, svc, ingress, cert, secrets) [hl-sy06]"Task 5: ArgoCD Application
Section titled “Task 5: ArgoCD Application”Files:
-
Create:
argocd/cluster-app/templates/miniflux.yaml -
Step 1: Create the Application — mirrors
mealie.yaml, adds theresources-finalizer(per the finalizer-per-Application lesson; fovea/agentgateway precedent)
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: miniflux namespace: argocd annotations: argocd.argoproj.io/sync-wave: "1" finalizers: - resources-finalizer.argocd.argoproj.iospec: project: core-services sources: - repoURL: https://github.com/fzymgc-house/selfhosted-cluster targetRevision: HEAD path: argocd/app-configs/miniflux destination: server: https://kubernetes.default.svc namespace: miniflux syncPolicy: automated: prune: true selfHeal: true syncOptions: - ServerSideApply=true - CreateNamespace=true ignoreDifferences: - kind: ExternalSecret group: external-secrets.io jqPathExpressions: - .spec.data[].remoteRef.conversionStrategy - .spec.data[].remoteRef.decodingStrategy - .spec.data[].remoteRef.metadataPolicy- Step 2: Validate —
cluster-appis a Helm chart of Application templates; render it
Run:
yamllint argocd/cluster-app/templates/miniflux.yamlhelm template argocd/cluster-app | grep -A2 "name: miniflux" | head && echo "helm render OK"Expected: no yamllint errors; the miniflux Application appears in the render. (If helm template needs values, use the same invocation ArgoCD uses for cluster-app; otherwise yamllint + a peer-file diff against mealie.yaml suffices.)
- Step 3: Commit
jj --no-pager commit -m "feat(argocd): register miniflux Application [hl-sy06]"Task 6: Land docs + manifests on main
Section titled “Task 6: Land docs + manifests on main”- Step 1: Advance the bookmark to the tip and push
# each task left a trailing empty change, so the real tip is @-jj --no-pager bookmark set feat/miniflux-feedreader -r @-jj --no-pager git push -b feat/miniflux-feedreader- Step 2: Open the PR
gh pr create --head feat/miniflux-feedreader \ --title "feat(miniflux): self-hosted RSS feedreader [hl-sy06]" \ --body "Deploys Miniflux 2.3.1 (CNPG main DB, Authentik OIDC, native Karakeep save). Spec + plan under docs/engineering/. Closes the design epic hl-sy06."- Step 3: Merge after CI + review. On merge: TFC
main-cluster-authentikapplies the OIDC client; ArgoCD syncs thecnpgandminifluxApplications.
Task 7: Verify the deployment
Section titled “Task 7: Verify the deployment”Files: none (runtime verification, read-only — Kubernetes MCP or kubectl read).
- Step 1: ArgoCD + pod health
kubectl --context fzymgc-house -n miniflux get podskubectl --context fzymgc-house -n miniflux get externalsecretExpected: miniflux-* pod Running/Ready; both ExternalSecrets SecretSynced. If the pod crash-loops on missing OIDC creds, confirm TFC applied and wait one 5-minute refresh.
- Step 2: Healthcheck
kubectl --context fzymgc-house -n miniflux exec deploy/miniflux -- wget -qO- localhost:8080/healthcheckExpected: OK.
- Step 3: Browser login — open
https://miniflux.fzymgc.house, click the Authentik OIDC login; confirm first login auto-provisions the user (OAUTH2_USER_CREATION=1). Break-glass: the local admin from Task 1 still works.
Task 8: Post-deploy operational cutover
Section titled “Task 8: Post-deploy operational cutover”Files: none (operational — Miniflux UI + Karakeep API).
-
Step 1: Import the 17 feeds — Miniflux → Settings → Import → OPML; upload the Reader OPML (
~/Downloads/Reader_Feeds.opml) or the cleaned list. Verify the feed count and that the first poll succeeds (Settings → Feeds shows no parse errors on the live ones). -
Step 2: Configure the Karakeep integration — Miniflux → Settings → Integrations → Karakeep: set API endpoint
https://karakeep.fzymgc.house/api/v1/bookmarks, paste the current Karakeep API key, optionally a comma-separated tag (e.g.from-miniflux). Save. -
Step 3: Connect a native client (optional) — point NetNewsWire (or chosen app) at
https://miniflux.fzymgc.housevia its Google Reader API / FreshRSS account type; confirm two-way read-state sync. -
Step 4: Cutover — disable the 11 remaining Karakeep auto-save feeds so feeds live only in Miniflux. Using the Karakeep API (key from
~/.claude.jsonmcpServers.karakeep.env.KARAKEEP_API_KEY):
KEY=$(python3 -c "import json;print(json.load(open('$HOME/.claude.json'))['mcpServers']['karakeep']['env']['KARAKEEP_API_KEY'])")for fid in $(curl -sS -H "Authorization: Bearer $KEY" \ https://karakeep.fzymgc.house/api/v1/feeds | python3 -c \ "import sys,json;[print(f['id']) for f in json.load(sys.stdin)['feeds'] if f['enabled']]"); do curl -sS -o /dev/null -w "disabled %{http_code}\n" -X PATCH \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"enabled":false}' "https://karakeep.fzymgc.house/api/v1/feeds/$fid"doneExpected: each PATCH returns 200. (The 6 firehose feeds are already disabled; this turns off the remaining 11.)
- Step 5: End-to-end verification — in Miniflux, open an entry and click Save; confirm it appears in Karakeep as a new bookmark and gets AI-tagged. Migration complete.
Spec coverage check
Section titled “Spec coverage check”| Spec decision | Task(s) |
|---|---|
| D1 Miniflux over FreshRSS | 4–5 (Miniflux manifests) |
| D2 Authentik OIDC (not forward-auth) | 3, 4 (OAUTH2_* env) |
D3 Shared main CNPG, no hstore | 2 |
| D4 Open-protocol client | 8 step 3 |
| D5 Read-all / save-keepers feeds model | 8 steps 1, 4 |
| D6 Karakeep key as in-app config | 8 step 2 |
| Validation flow | 7, 8 step 5 |