Skip to content

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.


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:

Terminal window
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:

Terminal window
vault kv put secret/fzymgc-house/cluster/miniflux \
admin_username=admin \
admin_password="$(openssl rand -base64 24)"
  • Step 3: Verify both paths

Run:

Terminal window
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 a managed.roles entry)
  • Modify: argocd/app-configs/cnpg/kustomization.yaml (register the two new files)
  • Step 1: Create the Database CR (db-miniflux.yaml) — mirrors db-mealie.yaml
---
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: miniflux
namespace: postgres
spec:
cluster:
name: main
ensure: present
name: miniflux
owner: miniflux
  • Step 2: Create the role-credentials ExternalSecret (users-miniflux.yaml) — mirrors users-mealie.yaml
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: main-miniflux-credentials
namespace: postgres
spec:
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.yaml under spec.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.yaml resources: (after the users-octopus.yaml line):
- db-miniflux.yaml
- users-miniflux.yaml
  • Step 5: Validate

Run:

Terminal window
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.yaml
kubectl kustomize argocd/app-configs/cnpg >/dev/null && echo "kustomize OK"

Expected: no yamllint errors; kustomize OK.

  • Step 6: Commit
Terminal window
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 — mirrors karakeep.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:

Terminal window
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
Terminal window
jj --no-pager commit -m "feat(authentik): OIDC provider + application for miniflux [hl-sy06]"

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: v1
kind: Namespace
metadata:
name: miniflux
  • Step 2: secrets.yaml — two ExternalSecrets (DB url + app creds), mirroring mealie/secrets.yaml
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: miniflux-db-secrets
namespace: miniflux
spec:
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/v1
kind: ExternalSecret
metadata:
name: miniflux-app-secrets
namespace: miniflux
spec:
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/v1
kind: Deployment
metadata:
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: v1
kind: Service
metadata:
name: miniflux
namespace: miniflux
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: miniflux
ports:
- protocol: TCP
port: 8080
targetPort: http
name: http
  • Step 5: certificate.yaml — mirrors mealie/certificate.yaml
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: miniflux-tls
namespace: miniflux
spec:
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, mirrors mealie/ingress.yaml
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
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/v1beta1
kind: Kustomization
namespace: miniflux
resources:
- namespace.yaml
- secrets.yaml
- deployment.yaml
- service.yaml
- certificate.yaml
- ingress.yaml
  • Step 8: Validate

Run:

Terminal window
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
Terminal window
jj --no-pager commit -m "feat(miniflux): app manifests (deployment, svc, ingress, cert, secrets) [hl-sy06]"

Files:

  • Create: argocd/cluster-app/templates/miniflux.yaml

  • Step 1: Create the Application — mirrors mealie.yaml, adds the resources-finalizer (per the finalizer-per-Application lesson; fovea/agentgateway precedent)

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: miniflux
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
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: Validatecluster-app is a Helm chart of Application templates; render it

Run:

Terminal window
yamllint argocd/cluster-app/templates/miniflux.yaml
helm 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
Terminal window
jj --no-pager commit -m "feat(argocd): register miniflux Application [hl-sy06]"

  • Step 1: Advance the bookmark to the tip and push
Terminal window
# 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
Terminal window
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-authentik applies the OIDC client; ArgoCD syncs the cnpg and miniflux Applications.

Files: none (runtime verification, read-only — Kubernetes MCP or kubectl read).

  • Step 1: ArgoCD + pod health
Terminal window
kubectl --context fzymgc-house -n miniflux get pods
kubectl --context fzymgc-house -n miniflux get externalsecret

Expected: 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
Terminal window
kubectl --context fzymgc-house -n miniflux exec deploy/miniflux -- wget -qO- localhost:8080/healthcheck

Expected: 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.

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.house via 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.json mcpServers.karakeep.env.KARAKEEP_API_KEY):

Terminal window
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"
done

Expected: 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 decisionTask(s)
D1 Miniflux over FreshRSS4–5 (Miniflux manifests)
D2 Authentik OIDC (not forward-auth)3, 4 (OAUTH2_* env)
D3 Shared main CNPG, no hstore2
D4 Open-protocol client8 step 3
D5 Read-all / save-keepers feeds model8 steps 1, 4
D6 Karakeep key as in-app config8 step 2
Validation flow7, 8 step 5