One of my favorite self-hosted apps running on my cluster is Mealie — a recipe manager that’s become a staple in our household. Sure, there are plenty of recipe apps out there, but Mealie does something that really sets it apart for me: it lets me import recipes from the web, store my own creations, and — this is the part that genuinely blew me away — use AI to convert handwritten or photographed recipes into a proper digital format.
My wife has recipes from her family that are multiple generations old. Being able to snap a picture of a handwritten recipe card and have Mealie’s AI turn it into a clean, structured recipe is an amazing feature. Those recipes are now preserved and searchable, which means a lot to us.
Beyond that, it handles meal planning, shopping lists, and keeps everything organized in one place. If you haven’t tried it, I highly recommend it.
How It’s Set Up
Like everything in my cluster, Mealie runs on Kubernetes and is deployed via Ansible. I broke the deployment into a few logical playbooks: creating the namespace and PVC, deploying the app itself, setting up the Traefik IngressRoute, and creating the Cloudflare DNS record.
Step 1: Namespace and Persistent Volume Claim
- name: Mealie PVC
hosts: master
gather_facts: false
vars:
desired_state: "present"
tasks:
- name: Create a mealie namespace
kubernetes.core.k8s:
kubeconfig: /etc/rancher/k3s/k3s.yaml
state: "{{ desired_state }}"
name: mealie
api_version: v1
kind: Namespace
- name: Create PVC
kubernetes.core.k8s:
kubeconfig: /etc/rancher/k3s/k3s.yaml
state: "{{ desired_state }}"
definition:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
namespace: mealie
labels:
io.kompose.service: mealie-data
name: mealie-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
Step 2: Deploying Mealie
A few things worth noting in the deployment:
ALLOW_SIGNUPis set tofalse— I don’t want anyone randomly creating accounts.OPENAI_API_KEYpowers the AI recipe conversion feature. This is stored in Ansible Vault as{{ openai_api_key }}.SMTP_PASSWORDis also vaulted. I use Gmail’s SMTP with app passwords.- Resources are capped at 1500m CPU and 1Gi memory, which has been plenty comfortable on my RK1 nodes.
- name: Mealie Deployment
hosts: master
gather_facts: false
vars:
desired_state: "present"
tasks:
- name: Deploy Mealie
kubernetes.core.k8s:
kubeconfig: /etc/rancher/k3s/k3s.yaml
state: "{{ desired_state }}"
definition:
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: mealie
labels:
io.kompose.service: mealie
name: mealie
spec:
replicas: 1
selector:
matchLabels:
io.kompose.service: mealie
strategy:
type: Recreate
template:
metadata:
labels:
io.kompose.service: mealie
spec:
containers:
- env:
- name: ALLOW_SIGNUP
value: "false"
- name: BASE_URL
value: https://mealie.yourdomain.com
- name: MAX_WORKERS
value: "1"
- name: PGID
value: "1000"
- name: PUID
value: "1000"
- name: TZ
value: America/Los_Angeles
- name: WEB_CONCURRENCY
value: "1"
- name: SMTP_HOST
value: "smtp.gmail.com"
- name: SMTP_PORT
value: "587"
- name: SMTP_FROM_NAME
value: "Mealie Recipes"
- name: SMTP_AUTH_STRATEGY
value: "TLS"
- name: SMTP_FROM_EMAIL
value: "me****@********in.com"
- name: SMTP_USER
value: "me****@********in.com"
- name: SMTP_PASSWORD
value: "{{ smtp_pass }}"
- name: OPENAI_API_KEY
value: "{{ openai_api_key }}"
image: ghcr.io/mealie-recipes/mealie:v3.9.2
name: mealie
ports:
- containerPort: 9000
protocol: TCP
resources:
limits:
cpu: 1500m
memory: 1024Mi
volumeMounts:
- mountPath: /app/data
name: mealie-data
restartPolicy: Always
volumes:
- name: mealie-data
persistentVolumeClaim:
claimName: mealie-data
- name: Create service
kubernetes.core.k8s:
kubeconfig: /etc/rancher/k3s/k3s.yaml
state: "{{ desired_state }}"
definition:
apiVersion: v1
kind: Service
metadata:
namespace: mealie
labels:
io.kompose.service: mealie
name: mealie
spec:
ports:
- name: "9925"
port: 9925
targetPort: 9000
selector:
io.kompose.service: mealie
Step 3: Traefik IngressRoute
This wires Mealie into Traefik using my wildcard cert from cert-manager — the same pattern I use for all my services.
- name: mealie (with wildcard cert)
hosts: master
become: true
gather_facts: false
vars:
desired_state: "present"
tasks:
- name: setup ingressroute for mealie.yourdomain.com
kubernetes.core.k8s:
kubeconfig: /etc/rancher/k3s/k3s.yaml
state: "{{ desired_state }}"
definition:
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: mealie
namespace: mealie
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`mealie.yourdomain.com`)
services:
- name: mealie
port: 9925
tls:
secretName: wildcard-cert-prod-yourdomain-tls
Step 4: Cloudflare DNS
💡 Note: For this step I used the Global API Key from my Cloudflare profile — not the scoped API token used for cert-manager. They’re different things and it tripped me up the first time.
- name: Mealie Cloudflare DNS
hosts: master
gather_facts: false
vars:
desired_state: "present"
tasks:
- name: Create DNS A record for mealie.yourdomain.com
community.general.cloudflare_dns:
zone: yourdomain.com
record: mealie
type: A
value: YOUR.PUBLIC.IP.HERE
proxied: true
account_email: "{{ your_email }}"
account_api_key: "{{ your_cloudflare_api_key }}"
state: "{{ desired_state }}"
Verifying the Deployment
Once everything is applied, work through these checks to confirm each layer is healthy.
Check the namespace and pod status:
kubectl get ns mealie
kubectl get pods -n mealie
You should see the Mealie pod in a Running state with 1/1 READY.
Check the deployment and replica status:
kubectl get deployment -n mealie
kubectl describe deployment mealie -n mealie
Confirm the service is up:
kubectl get svc -n mealie
You should see the mealie service with port 9925 mapped to target port 9000.
Check the PVC is bound:
kubectl get pvc -n mealie
The mealie-data PVC should show a status of Bound. If it’s stuck in Pending, check your storage class — in my case Longhorn handles this.
Verify the IngressRoute:
kubectl get ingressroute -n mealie
kubectl describe ingressroute mealie -n mealie
Confirm the TLS secret is present:
kubectl get secret wildcard-cert-prod-yourdomain-tls -n mealie
If this comes back empty, the reflector plugin hasn’t propagated the cert to the mealie namespace yet — give it a moment and check again.
Check the logs if something isn’t right:
kubectl logs -n mealie deployment/mealie
This is usually the fastest way to spot startup issues, missing env vars, or database errors.
Test routing internally:
curl -vk https://traefik.kube-system.svc.cluster.local -H "Host: mealie.yourdomain.com"
If Mealie responds, the Traefik routing is working correctly end to end.
Wrapping Up
Mealie has been one of the most used apps in my homelab — not by me tinkering, but by my family actually using it daily. The AI photo-to-recipe conversion alone made it worth setting up. If you’re running a Kubernetes cluster at home and haven’t tried Mealie yet, I’d strongly recommend giving it a shot.
As always, make sure your sensitive values (smtp_pass, openai_api_key, Cloudflare API key) are stored in Ansible Vault and never hardcoded in your playbooks.