silly business

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-db

It's not useful as a finished product, but it's a good starting point.

Defining the Kubernetes Objects

To be deployed, Miniflux needs:

  1. The Pod definition based on above.
  2. A Service definition based on above, to expose the Pods to the cluster and local network.
  3. A Secrets definition for the passwords
  4. A PersistentVolumeClaim for the volume that the database data inhabits.
  5. An Ingress to tell the service listening on :80 and :443 (in Kubernetes this is the Ingress Controller and in k3s the choice of Ingress Controller is Traefik) to forward traffic to the Service when 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-pvc

Service

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: 8080

Storage

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: bmljZS10cnktYnV0LUknbS1ub3Qtc3R1cGlk

Ingress

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.yaml

and 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: traefik
The 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:

Screenshot_20260104_141559.png

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:

  1. 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 Gateway API), reconfiguration should be minimal and anything I need to learn will be useful repeatedly.
  2. 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.
  3. The deployment is now completely automated and documented, without writing anything extra. I can use git to 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 have kubectl configured. 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.
  4. 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

  • k3s is a good, self-contained product for standing up a Kubernetes node & server for minimal fuss
  • podman kube generate is a very good and cool tool for bridging the gap from Docker Compose to Kubernetes, and podman compose is 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


1

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.

2

I haven't been careful with my Miniflux deployments in the past..

3

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.

4

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.

5

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