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:
- Linkding: A fast, minimalist bookmark manager with full-text search, tagging, and automated webpage archiving.
- 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-provisionerCSI 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:
- Root Cluster Sync: Watches
clusters/staging/to keep core cluster controllers and CRDs synchronized. - Application Workload Sync: A dedicated
Kustomizationresource (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:
- Two pods attempting to read/write simultaneously to the same SQLite file will trigger
database is lockedor synchronization errors. - 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
- Zero-Drift Homelab: Any manual edits in Kubernetes get automatically reverted by Flux to match Git.
- Kustomize Keeps Things Clean: Splitting
baseandstagingensures we can easily add aproductioncloud cluster (like AKS) later without rewriting our app definitions. - 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
.homelabDNS. - 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!


