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!
Top comments (1)
Linkding and Mealie on k3s is a weirdly wholesome stack. Did the SQLite bits fight back?