Deploying Mealie on Kubernetes: Recipe Management with a Personal Touch

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_SIGNUP is set to false — I don’t want anyone randomly creating accounts.
  • OPENAI_API_KEY powers the AI recipe conversion feature. This is stored in Ansible Vault as {{ openai_api_key }}.
  • SMTP_PASSWORD is 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.

Leave a Comment