DEV Community

Alan Varghese
Alan Varghese

Posted on

Building a Declarative Homelab: Deploying Linkding & Mealie to Kubernetes with Flux CD GitOps

A practical walkthrough of deploying self-hosted Linkding and Mealie on local Kubernetes using Flux CD v2, Kustomize overlays, and automated GitOps continuous reconciliation.

If you have ever spent a weekend setting up self-hosted services using docker compose or ad-hoc kubectl apply commands, you know the inevitable pain: configuration drift, forgotten secrets, missing volume permissions, and that nagging feeling of "wait, where did I write down that config change?"

Recently, I decided to overhaul my homelab workflow and do things properly: 100% declarative GitOps on Kubernetes, powered by Flux CD (v2), Kustomize, and a reproducible development environment via DevPod on Rancher Desktop (k3s).

To validate this pipeline end-to-end, I deployed two of my favorite self-hosted services:

  1. Linkding: A fast, minimalist bookmark manager with full-text search, tagging, and automated webpage archiving.
  2. Mealie: A recipe manager and meal planner that can scrape recipes from virtually any URL.

In this post, I will share the architectural setup, manifest breakdown, real-world gotchas (especially with SQLite on Kubernetes!), and how GitOps keeps everything running in perfect sync.


πŸ›  The Homelab Tech Stack

Here is what powers the underlying lab:

  • Local Kubernetes Runtime: Rancher Desktop running k3s, exposing the native Docker socket and utilizing the local-path-provisioner CSI for storage.
  • GitOps Engine: Flux CD (v2) continuously watching our GitHub repository and reconciling state with zero manual intervention.
  • Packaging & Templating: Kustomize using a clean Base + Environment Overlay pattern.
  • Developer Environment: DevPod running inside an isolated devcontainer, keeping the local host clean while managing CLI tools via Mise.

πŸ— GitOps Architecture: How It Works

Instead of manually deploying manifests with kubectl, the Git repository is the single source of truth.

GitHub Repository (main branch)
  β”‚
  β”œβ”€β”€ 1. clusters/staging/flux-system/gotk-sync.yaml
  β”‚      └── Flux pulls repo every 1m -> Kustomization (flux-system)
  β”‚
  └── 2. clusters/staging/apps.yaml
         └── Kustomization (apps) -> points to ./apps/staging (prune: true)
                β”‚
                β”œβ”€β”€ apps/staging/linkding/ ──► apps/base/linkding/
                └── apps/staging/mealie/   ──► apps/base/mealie/
Enter fullscreen mode Exit fullscreen mode

The Two-Tier Synchronization Flow

Flux uses a two-tier reconciliation model:

  1. Root Cluster Sync: Watches clusters/staging/ to keep core cluster controllers and CRDs synchronized.
  2. Application Workload Sync: A dedicated Kustomization resource (apps.yaml) watches ./apps/staging.

Flux’s kustomize-controller scans the directory tree. Each subfolder containing a kustomization.yaml is built, validated, and applied automatically. If any manifest in Git is removed, prune: true ensures Kubernetes garbage-collects the decommissioned resource.


πŸ“ Repository Layout: Base + Overlays

To keep things modular and DRY (Don't Repeat Yourself), the repo is organized cleanly:

.
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ base/
β”‚   β”‚   β”œβ”€β”€ linkding/             # Reusable base manifests
β”‚   β”‚   β”‚   β”œβ”€β”€ deployment.yaml
β”‚   β”‚   β”‚   β”œβ”€β”€ service.yaml
β”‚   β”‚   β”‚   β”œβ”€β”€ storage.yaml
β”‚   β”‚   β”‚   β”œβ”€β”€ namespace.yaml
β”‚   β”‚   β”‚   └── kustomization.yaml
β”‚   β”‚   └── mealie/               # Reusable base manifests
β”‚   β”‚       β”œβ”€β”€ deployment.yaml
β”‚   β”‚       β”œβ”€β”€ service.yaml
β”‚   β”‚       β”œβ”€β”€ storage.yaml
β”‚   β”‚       β”œβ”€β”€ namespace.yaml
β”‚   β”‚       └── kustomization.yaml
β”‚   └── staging/                  # Environment overlays
β”‚       β”œβ”€β”€ linkding/kustomization.yaml # References ../../base/linkding/
β”‚       └── mealie/kustomization.yaml   # References ../../base/mealie/
β”œβ”€β”€ clusters/
β”‚   └── staging/
β”‚       β”œβ”€β”€ apps.yaml             # Workload entrypoint for Flux
β”‚       └── flux-system/          # Core Flux Toolkit manifests
Enter fullscreen mode Exit fullscreen mode

πŸ“¦ App 1: Deploying Linkding

Linkding is a self-hosted Django application that saves bookmarks and metadata in a database.

1. Storage (apps/base/linkding/storage.yaml)

We provision persistent storage using a standard PersistentVolumeClaim:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: linkding-data-pvc
  namespace: linkding
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
Enter fullscreen mode Exit fullscreen mode

2. Deployment (apps/base/linkding/deployment.yaml)

Notice the security parameters: we enforce non-root execution (UID 33 for www-data) and disallow privilege escalation:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: linkding
  namespace: linkding
spec:
  replicas: 1
  selector:
    matchLabels:
      app: linkding
  template:
    metadata:
      labels:
        app: linkding
    spec:
      securityContext:
        fsGroup: 33
        runAsUser: 33
        runAsGroup: 33
      containers:
        - name: linkding
          image: sissbruecker/linkding:1.31.0
          ports:
            - containerPort: 9090
          securityContext:
            allowPrivilegeEscalation: false
          volumeMounts:
            - name: linkding-data
              mountPath: etc/linkding/data
      volumes:
        - name: linkding-data
          persistentVolumeClaim:
            claimName: linkding-data-pvc
Enter fullscreen mode Exit fullscreen mode

3. Service (apps/base/linkding/service.yaml)

An internal ClusterIP exposes the container port on 9090:

apiVersion: v1
kind: Service
metadata:
  name: linkding
  namespace: linkding
spec:
  type: ClusterIP
  selector:
    app: linkding
  ports:
    - port: 9090
Enter fullscreen mode Exit fullscreen mode

🍲 App 2: Deploying Mealie

Mealie is built with FastAPI and Vue.js. It features automatic recipe parsing using Schema.org metadata.

1. Storage & Namespace (apps/base/mealie/storage.yaml)

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mealie-data-pvc
  namespace: mealie
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 500Mi
Enter fullscreen mode Exit fullscreen mode

2. Deployment & Service (apps/base/mealie/deployment.yaml)

Similar to Linkding, Mealie runs unprivileged with restricted permissions, listening on port 9000:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mealie
  namespace: mealie
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mealie
  template:
    metadata:
      labels:
        app: mealie
    spec:
      securityContext:
        fsGroup: 33
        runAsUser: 33
        runAsGroup: 33
      containers:
        - name: mealie
          image: ghcr.io/mealie-recipes/mealie:v1.3.0
          ports:
            - containerPort: 9000
          securityContext:
            allowPrivilegeEscalation: false
          volumeMounts:
            - mountPath: /app/data
              name: mealie-data
      volumes:
        - name: mealie-data
          persistentVolumeClaim:
            claimName: mealie-data-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: mealie
  namespace: mealie
spec:
  type: ClusterIP
  selector:
    app: mealie
  ports:
    - port: 9000
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ The Real-World Gotcha: The SQLite Replica Trap

When first authoring Kubernetes manifests, it is tempting to set replicas: 2 on your deployments out of habit for high availability.

However, both Linkding and Mealie run embedded SQLite databases by default:

  • Linkding writes to /etc/linkding/data/db.sqlite3
  • Mealie writes to /app/data/mealie.db

Because SQLite uses local filesystem locking and the storage backend is mounted via a ReadWriteOnce (RWO) PVC:

  1. Two pods attempting to read/write simultaneously to the same SQLite file will trigger database is locked or synchronization errors.
  2. Standard RWO volumes on local storage provisioners cannot be concurrently attached across differing nodes or pod boundaries.

The Solution:
For homelab use with embedded SQLite databases, set replicas: 1. If you genuinely need multi-replica horizontal scaling, the right design pattern is to plug both applications into an external PostgreSQL cluster via environment variables (e.g. LD_DB_ENGINE: postgres and DB_ENGINE: postgres).

Updating this in GitOps was effortless: updating replicas: 1 in deployment.yaml and pushing to Git caused Flux to scale the cluster down cleanly within 60 seconds!


πŸš€ Accessing the Services

Since the services are currently exposed as ClusterIP, we can access them locally via kubectl port-forward:

1. Accessing Linkding

kubectl port-forward -n linkding svc/linkding 9090:9090
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:9090.

Because Linkding does not provide a default password, create your admin user using Django's management command:

kubectl exec -it -n linkding deploy/linkding -- python manage.py createsuperuser
Enter fullscreen mode Exit fullscreen mode

2. Accessing Mealie

kubectl port-forward -n mealie svc/mealie 9000:9000
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:9000.

Mealie initializes with default credentials:

  • Email: changeme@example.com
  • Password: MyPassword (Make sure to update these immediately in your profile settings!)

πŸ” Checking Health with Flux CLI

One of the best parts about Flux CD is its diagnostic CLI:

# View all synchronized resources across the homelab
flux get all -A

# Check git pull status
flux get sources git

# Trigger an immediate forced sync from Git
flux reconcile kustomization apps --with-source
Enter fullscreen mode Exit fullscreen mode

Output:

NAMESPACE    NAME                REVISION            SUSPENDED  READY  MESSAGE
flux-system  gitrepository/...   main@sha1:c6f48ffc  False      True   stored artifact for revision 'main@sha1:c6f48ffc'
flux-system  kustomization/apps  main@sha1:c6f48ffc  False      True   Applied revision: main@sha1:c6f48ffc
Enter fullscreen mode Exit fullscreen mode

🎯 Key Takeaways

  1. Zero-Drift Homelab: Any manual edits in Kubernetes get automatically reverted by Flux to match Git.
  2. Kustomize Keeps Things Clean: Splitting base and staging ensures we can easily add a production cloud cluster (like AKS) later without rewriting our app definitions.
  3. Know Your State: Always align replica counts with your database architectureβ€”embedded SQLite requires single-pod deployments, while distributed databases unlock horizontal scale.

What's Next?

Next on the roadmap:

  • Exposing apps via Traefik Ingress with local .homelab DNS.
  • Automated local SSL certs using cert-manager.
  • Encrypted Git secrets management with SOPS or Sealed Secrets.

What self-hosted services are you running on Kubernetes? How do you structure your GitOps repositories? Let me know in the comments below!

Top comments (1)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Linkding and Mealie on k3s is a weirdly wholesome stack. Did the SQLite bits fight back?