Running Miniflux in k3s and Narrating it in Org for Questionable Reasons
My day jobs have entailed occasional interaction with Kubernetes for a long time, and I've been an accidental participant in the "hobby" of self-hosting1 since high school so I have been meaning to incorporate k3s somewhere in my stack for a long time to encourage me to stay fresh with some of the aspects of the tools I use professionally that I don't always encounter day-to-day.
k3s is a Kubernetes distribution in a single binary. The installer from their website downloads the server binary to /usr/local/bin and then configures and starts it as a systemd service. It then installs kubectl, along with a couple other helper programs, and configures them to point to the new server at localhost.
The containers are all containerd containers so if you are running docker or podman on the same system, the containers inside k3s are all isolated and don't pollute docker ps. Aside from a few extra virtual network devices, the new .service file, and the extra binaries, the new host is unchanged, and I'm still able to run normal services alongside my new Kubernetes node.
After running the install line on a brand new Debian host, I wanted to set up an example service to validate the installation. I chose to install Miniflux since that's my favorite RSS feed reader and I didn't currently have an instance running. I have done the Miniflux installation a few times now2, and one of the things I like about Miniflux is that it's about as simple as it gets. It's just a binary, so you just put it where it goes (/usr/local/bin) and write a .service file and you're off. Except, oh that's right, Miniflux uses Postgres to store state.
This drives me nuts! Why can't it use sqlite?? That would be so perfect and so easy to deploy! Anyway, I assume there's a good reason, but it means I have to keep Postgres around. That makes miniflux a good candidate for being the proof of concept for my new k3s cluster. It's a relatively simple service, but not one that is completely trivial, either.
Configuring the Service
As efficient as any good engineer, I wanted to avoid handwriting as much YAML as I could while also learning how to adapt a self-hosted service to run in k3s. Most projects like Miniflux are small community affairs which do not offer Kubernetes configuration or something like an Operator. However, a lot of projects offer a Docker Compose configuration, and suggestion that you run your service using Docker Compose3.
This is useful to me because podman has a command podman kube generate that lets you generate Kubernetes definitions from podman objects (containers, pods, volumes, etc).
Generating the Kubernetes Pod definition from the Docker Compose file
The first step is fetching the Miniflux source, editing the docker-compose.yaml according to the Miniflux installation instructions to select admin and database credentials, and then running podman compose up -d.
Then, I ran podman kube generate --service pod_miniflux to generate most of the configuration I would need for k3s.
For later comparison, this is what that output looks like:
# Save the output of this file and use kubectl create -f to import
# it into Kubernetes.
#
# Created with podman-5.4.2
# NOTE: The namespace sharing for a pod has been modified by the user and is not the same as the
# default settings for kubernetes. This can lead to unexpected behavior when running the generated
# kube yaml in a kubernetes cluster.
---
apiVersion: v1
kind: Service
metadata:
creationTimestamp: "2026-01-04T20:36:42Z"
labels:
app: podminiflux
name: podminiflux
spec:
ports:
- name: "8080"
nodePort: 31462
port: 8080
targetPort: 8080
selector:
app: podminiflux
type: NodePort
---
apiVersion: v1
kind: Pod
metadata:
annotations:
io.kubernetes.cri-o.SandboxID/minifluxdb1: 0a359571a6d6b2b0db840eb377f2ae39a3375562ead5e9ba1fe0789ec78bdac5
io.kubernetes.cri-o.SandboxID/minifluxminiflux1: 0a359571a6d6b2b0db840eb377f2ae39a3375562ead5e9ba1fe0789ec78bdac5
creationTimestamp: "2026-01-04T20:36:42Z"
labels:
app: podminiflux
name: podminiflux
spec:
containers:
- args:
- postgres
env:
- name: POSTGRES_PASSWORD
value: Very-Secret-DB-Password
- name: POSTGRES_DB
value: miniflux
- name: POSTGRES_USER
value: miniflux
image: docker.io/library/postgres:18
name: minifluxdb1
volumeMounts:
- mountPath: /var/lib/postgresql
name: miniflux-db-pvc
- env:
- name: RUN_MIGRATIONS
value: "1"
- name: CREATE_ADMIN
value: "1"
- name: ADMIN_USERNAME
value: ian
- name: ADMIN_PASSWORD
value: My-Secret-Admin-Password1
- name: DATABASE_URL
value: postgres://miniflux:Very-Secret-DB-Password@db/miniflux?sslmode=disable
image: docker.io/miniflux/miniflux:latest
name: minifluxminiflux1
ports:
- containerPort: 8080
securityContext:
runAsNonRoot: true
volumes:
- name: miniflux-db-pvc
persistentVolumeClaim:
claimName: miniflux-dbIt's not useful as a finished product, but it's a good starting point.
Defining the Kubernetes Objects
To be deployed, Miniflux needs:
- The
Poddefinition based on above. - A
Servicedefinition based on above, to expose thePodsto the cluster and local network. - A
Secretsdefinition for the passwords - A
PersistentVolumeClaimfor the volume that the database data inhabits. - An
Ingressto tell the service listening on:80and:443(in Kubernetes this is the Ingress Controller and in k3s the choice of Ingress Controller is Traefik) to forward traffic to theServicewhen the correct hostname is requested on those ports.
Since Kubernetes objects are defined declaratively, these 5 YAML objects can be stored in my systems journals repository4 in a single Org file or under an Org heading, which can then be executed to deploy or redeploy the entire service.
I love this because, although there is potentially more work up-front, in the future I can have all of the configuration, automation, and commentary in the same files in the location where I keep those things for all of my systems, and in a format that is more robust and executable than some of my more manual deployments. Jump to Saving and Applying the Configuration below if you are more interested in this than in the Kubernetes configuration.
Here are the final configurations, in the mostly arbitrary order from above:
Pod Definition
The final Pod definition isn't much different from the one that podman spit out. I removed stuttering from some names and moved the hard-coded secrets to the Secrets object.
---
apiVersion: v1
kind: Pod
metadata:
labels:
app.kubernetes.io/name: miniflux
name: miniflux
spec:
containers:
- name: db
args:
- postgres
env:
- name: POSTGRES_DB
value: miniflux
- name: POSTGRES_USER
value: miniflux
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: miniflux-secrets
key: database-password
image: docker.io/library/postgres:18
volumeMounts:
- mountPath: /var/lib/postgresql
name: miniflux-db
- name: app
env:
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: miniflux-secrets
key: admin-password
- name: DATABASE_URL
value: postgres://miniflux:$POSTGRES_PASSWORD@localhost/miniflux?sslmode=disable
- name: RUN_MIGRATIONS
value: "1"
- name: CREATE_ADMIN
value: "1"
- name: ADMIN_USERNAME
value: "ian"
image: docker.io/miniflux/miniflux:latest
ports:
- containerPort: 8080
securityContext:
runAsNonRoot: true
volumes:
- name: miniflux-db
persistentVolumeClaim:
claimName: miniflux-db-pvcService
The service definition is pretty close too. I changed the type to a LoadBalancer after reading some of the k3s docs and changed some ports based on personal taste:
---
apiVersion: v1
kind: Service
metadata:
name: miniflux-service
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: miniflux
ports:
- protocol: TCP
port: 8111
targetPort: 8080Storage
Postgres needs persistent storage, which means it claims a volume with volumeMounts above. That claim needs a PersistentVolumeClaim which is defined here:
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: miniflux-db-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
podman kube generate $VOLUMENAME generated most of this, but I needed to remove the podman annotation and add the storageClassName line to make it compatible with k3s.
Secrets
Kubernetes allows secrets to be managed through their own object, defined in their own file. They are base64 encoded in the file, but exposed in the pods in plain text. This allows me to use org-crypt5 to quickly encrypt/decrypt the Org heading containing secrets.yaml with my GPG key and store the secrets encrypted at rest.
In plain text, the Secrets definition looks like this:
---
apiVersion: v1
kind: Secret
metadata:
name: miniflux-secrets
type: Opaque
data:
admin-password: SS1CZXQtWW91LVRoaW5rLVlvdXJlLUNsZXZlcg==
database-password: bmljZS10cnktYnV0LUknbS1ub3Qtc3R1cGlkIngress
The Ingress definition provides the link from Traefik listening on 80 and 443 to the Service inside the cluster. However, if we define the Ingress on k3s immediately after installing vanilla k3s, the Ingress will present a self-signed certificate.
Let's Encrypt Certificate Issuance
I would like services to be assigned Let's Encrypt certificates automatically, because I have been spoiled by the functionality of Caddy. This requires a one-time task to set up automated certificate issuance for any new Services or Deployments that I put in my cluster. After this I will be able to issue a certificate for an Ingress by adding an annotation to that object.
The Kubernetes object that provides this functionality is a ClusterIssuer. Certificate Manager appears to be the standard implementation for my use-case and it can be installed thusly:
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.2/cert-manager.yamland then configured with yet another YAML document, which can be found in many examples and merely needs the email field filled in, and then applied:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
email: [email protected]
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt
solvers:
- http01:
ingress:
class: traefikThe Ingress Configuration
Once the ClusterIssuer has been defined, I can define my Ingress.
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: miniflux-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt
kubernetes.io/ingress.class: traefik
spec:
rules:
- host: news.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: miniflux-service
port:
number: 8111
tls:
- hosts:
- news.example.com
secretName: miniflux-tls
The cert-manager.io/cluster-issuer annotation tells the ClusterIssuer to contact Let's Encrypt to issue a certificate for the hostname defined in the tls section.
Saving and Applying the Configuration
I have defined each of these documents as part of source code blocks in an Org file dedicated to the server on which k3s was installed. The first part of the document combines notes about how to use the file, as well as automation to set up kubectl and decrypt any secrets and extract the code stored within the file to their final destinations
#+TITLE: Ian's Self Host Stack
#+PROPERTY: header-args: :tangle no
#+PROPERTY: header-args:yaml :eval never
This is a living, executable document, representing the desired state of my home services and infrastructure.
Run =org-babel-execute-buffer= with =kubectl= installed and configured to set up everything.
The document assumes a bare Linux server (anything that can run =k3s=) configured in =~/.ssh/config= under the ~Host~ =relay= with keys set up and ~curl~ installed.
When the buffer is executed, the following code will decrypt any encrypted sections and tangle the file:
#+begin_src emacs-lisp :results none
(progn
(org-decrypt-entries)
(org-babel-tangle-file (buffer-file-name))
(org-encrypt-entries))
#+end_src
Then it will copy the =kubectl= config file into place (overwrites =~/.kube/config= âš ) & point it to the correct hostname.
#+begin_src fish :results none
scp relay:/etc/rancher/k3s/k3s.yaml ~/.kube/config
sed -i 's/127.0.0.1/relay.local/g' ~/.kube/config
#+end_src
Under each heading I can define the parts of the server I want to configure, and Kubernetes makes this very straightforward as I can define a heading with a property that applies :tangle filename.yaml to all of the child blocks:
* Services
** Miniflux
:PROPERTIES:
:header-args:yaml: :tangle /tmp/miniflux.yaml
:END:
[[https://miniflux.app][Miniflux]] is a news feed (RSS/Atom) reader.
It can also include the kubectl apply calls since their order does not matter:
#+begin_src fish
kubectl get namespace miniflux || kubectl create namespace miniflux
#+end_src
#+RESULTS:
: NAME STATUS AGE
: miniflux Active 3h5m
#+begin_src fish
kubectl apply -f /tmp/miniflux.yaml -n miniflux
#+end_src
In practice, this allows me to call org-babel-execute-subtree to redeploy Miniflux, or org-babel-execute-buffer to completely redeploy all of my services and the Kubernetes configuration itself.
Here is a screenshot of how this document appears (with some headings collapsed) in Emacs with my configuration in light mode:

Reflections on the Old Way and the New Way
Until now I have just run Miniflux as a traditional Unix service, along with Postgres running as a traditional Unix service, on an LTS distribution, the "old fashioned way." Other than my penchant for accidentally deleting the database, this has been extremely low maintenance. Rewriting the configuration into YAML was definitely more effort, and for questionable gain.
When using Caddy as a reverse proxy, Miniflux can be set up with a single .service file and about four lines of Caddyfile configuration, plus the Postgres database. I can set that all up manually in about 15 minutes, maybe a little longer if I'm also watching TV. It's not hard.
Caddy itself is also very easy to deploy, and it gives the user the same functionality as all of the configuration in the Ingress and the ClusterIssuer, much of it by default with no extra configuration (particularly the Let's Encrypt stuff, which Caddy completely automates).
From that perspective, this is massive overkill and a lot more effort, and Traefik must be configured through Kubernetes verbs if I want to use it as the reverse proxy for all of my services, which is likely going to be more difficult than updating a Caddyfile. These are the downsides.
But here are some benefits of using k3s too:
- A lot of the config above isn't unique to Miniflux, or Traefik, and if k3s chooses a new Ingress Controller (or switches over to the newer
GatewayAPI), reconfiguration should be minimal and anything I need to learn will be useful repeatedly. - Kubernetes's declarative style works immensely well with Org Mode for literate configuration. This is a really huge win, as the same thing -can- be achieved with traditional tools but an Org document containing Kubernetes config can be reorganized more freely than a traditional literate script because Kubernetes is declarative. This is a light-bulb moment for me, and I will have to revisit this idea.
- The deployment is now completely automated and documented, without writing anything extra. I can use
gitto move around the repo containing this file, and to track changes, and execute the file to stand up all of the infrastructure on any system where I havekubectlconfigured. Every deployment I add will follow the same pattern, as well, which aids in one of the hardest tasks of the long-term solo self-hoster: remembering what, why, and how, you have configured your services over the course of years. - Every deployment gets its own versions of their dependencies and runs in its own namespace. This isolation is extremely nice when you have an ever-growing list of heterogeneous services which may have conflicting dependencies. In the past I have been afraid to run certain services without a VM or a physical host due to the number of dependencies they have, for fear of conflicting configurations. This will be much easier with Kubernetes.
Conclusions
k3sis a good, self-contained product for standing up a Kubernetes node & server for minimal fusspodman kube generateis a very good and cool tool for bridging the gap from Docker Compose to Kubernetes, andpodman composeis good and cool too.- YAML definitions and Org Mode are awesome together
- Kubernetes is, in fact, as complicated as its reputation implies
- Time will tell if this was worth the investment for a small-time operator like me, aside from the educational aspect.
Footnotes
Self-hosting isn't something I really do for fun but more out of stubbornness about having access to and ownership of the data I produce. This sounds like an ideological stance and it sort of is one, but it's really just practical; I want direct access to the databases that hold the data I produce so that I can query and modify it directly without being beholden to some gatekeeper. Worse, cloud providers may suddenly cease to exist, or may lock you out of your own data for any reason and with no recourse. Instead, I deal with drive failures. Is this wise? I'm not sure I'm saying that; at this point it's just how I live. Encountering people who run homelabs solely for fun is always sort of curious to me – I share their interests and I'm excited to help or learn, but it makes me feel like a mountain man encountering through-hikers. They're doing this for enrichment or entertainment; I just live here.
I haven't been careful with my Miniflux deployments in the past..
I don't like using Docker Compose for "production services" because I like to use the Docker endpoint on the same host as an ad-hoc container host itself, for random stuff. If I am running real workloads on the same Docker host, I have to be careful when cleaning up or experimenting, and being careful is a goal in opposition to free experimentation. These problems could be solved with more hosts, with VMs, or maybe with Docker Compose settings that I'm unaware of, but Docker Compose has long been a tool that chooses default behaviors I find frustrating and as such I tend to make different choices.
I talked about my practice of storing configuration as Org Mode files in a private repository and using Emacs TRAMP to install those files on the appropriate hosts in this old post.
Org Crypt is pre-configured for this repository using a .dir-locals.el variable that tells Emacs what GPG key to use. The documentation for this first-party feature is here:
https://www.gnu.org/software/emacs/manual/html_node/org/Org-Crypt.html