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/ 

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 

📦 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 

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 

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 

🍲 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 

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 

💡 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 

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 

2. Accessing Mealie

kubectl port-forward -n mealie svc/mealie 9000:9000 

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 

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 

🎯 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!