We will be deploying Mealie (self-hosted recipe manager) on a k3s cluster using a Deployment and LoadBalancer Service.
How We Found the Image
Mealie does not publish a dedicated Kubernetes manifest or Helm chart. The process was:
- Found the Docker image reference first —
docker pull ghcr.io/mealie-recipes/mealie - Went to the GitHub Container Registry page to find available tags
- Picked a stable release tag (
v3.13.0) rather thanlatestornightly— pinning a version is best practice so upgrades are deliberate and controlled - Used that image reference directly in the Kubernetes Deployment manifest
This is a common pattern — many self-hosted apps only publish Docker images. We find the image, check the registry for stable tags, and build oir own k8s manifests around it.
Container Image
- Registry:
ghcr.io/mealie-recipes/mealie - Registry page: https://github.com/mealie-recipes/mealie/pkgs/container/mealie
- Port:
9000(hardcoded in the container — always use this)
Step 1 — Create the Namespace
kubectl create namespace mealieSet the namespace as default context so we don’t have to type -n mealie every time:
k config set-context default --namespace=mealie
k config get-contextsExpected output:
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* default default default mealieStep 2 — Create the Deployment
Create mealie.yaml:
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: mealie
name: mealie
namespace: mealie
spec:
replicas: 1
selector:
matchLabels:
app: mealie
template:
metadata:
labels:
app: mealie
spec:
containers:
- image: ghcr.io/mealie-recipes/mealie:v3.13.0
name: mealie
ports:
- containerPort: 9000Apply it:
k apply -f mealie.yaml
k get pods -o wide
Expected output:
NAME READY STATUS RESTARTS AGE IP NODE
mealie-6f6cfcb6fc-g2zfn 1/1 Running 0 17s 10.42.0.151 k3s-cp-01
k get deployments.apps
NAME READY UP-TO-DATE AVAILABLE AGE
mealie 1/1 1 1 28s
Step 3 — Test with Port Forward
Before creating a proper service, test the pod is working with port-forward:
k port-forward services/mealie 9000:9000 --address 0.0.0.0
--address 0.0.0.0is required to bind on all interfaces — without it, only127.0.0.1(the node itself) is accessible, not external IPs.
Test from your machine:
curl -v http://172.16.77.10:9000/
- Trying 172.16.77.10:9000…
- Established connection to 172.16.77.10 (172.16.77.10 port 9000) from 192.168.0.47 port 64018
- using HTTP/1.x
GET / HTTP/1.1
Host: 172.16.77.10:9000
User-Agent: curl/8.18.0
Step 4 — Expose as LoadBalancer Service
First expose it quickly with kubectl expose, then export to yaml to clean it up:
k expose deployment mealie --port 9000
k get services mealie -o yaml > services.ymlEdit services.yml — strip all the auto-generated noise and set type to LoadBalancer:
apiVersion: v1
kind: Service
metadata:
labels:
app: mealie
name: mealie
namespace: mealie
spec:
ports:
- port: 9000
protocol: TCP
targetPort: 9000
selector:
app: mealie
type: LoadBalancerDelete the old ClusterIP service and apply the clean one:
k delete svc mealie
k apply -f services.yml
k get svcExpected output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
mealie LoadBalancer 10.43.12.132 172.16.77.10,172.16.77.11,172.16.77.12 9000:31952/TCP 21sHow the LoadBalancer Works in k3s
k3s uses Klipper as its built-in load balancer. It works by assigning all our node IPs as external IPs, so traffic to any of our nodes on that port gets routed to the service.
That’s why we see all 3 IPs:
172.16.77.10— k3s-cp-01172.16.77.11— worker-01172.16.77.12— worker-02
Klipper exposes the service on all node IPs. So if any one node goes down, we can still reach mealie via the other two IPs.
Final Result
Mealie is accessible at:
http://172.16.77.10:9000http://172.16.77.11:9000http://172.16.77.12:9000
Commit to Git
git add mealie.yaml services.yml
git commit -m "Add mealie deployment and LoadBalancer service"
git push

