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 (2)
The SQLite replica trap is a good warning, because scaling to two replicas feels like the safe default until the database locking proves otherwise. When you later move to Postgres for scaling, would you keep the same base and overlay layout, or does the database move force a different split between shared and environment specific manifests?
Linkding and Mealie on k3s is a weirdly wholesome stack. Did the SQLite bits fight back?