Homelab Haven

Self-Hosted Recipe Manager: Mealie and Tandoor Side by Side

July 20, 20267 min read

New to Docker Compose? Start with the basics first.

I run both Mealie and Tandoor, on purpose, at the same time. Not because I couldn't decide — because the only honest way to know which one fits how your household actually cooks is to use both for a while and see which one you keep opening. This post has the compose for each, and what I've actually learned running them side by side, not a "just pick this one" verdict.

Summary

  • Both are self-hosted recipe managers: recipes, tags/categories, meal planning, shopping lists. Mealie is a single container; Tandoor needs a Postgres database alongside it.
  • Tandoor has one real setup trap — a Django ALLOWED_HOSTS mismatch that returns a 400 error with a message that doesn't obviously point at the fix.
  • Migrating recipes from Mealie to Tandoor is one-directional and lossy in specific, predictable ways — images, ratings, and per-step titles don't survive. Know that before you commit to one.

What these actually are

Both solve the same everyday problem: recipes scattered across bookmarks, screenshots, a note app, and three different cooking sites that each want you to scroll past someone's childhood story to reach the ingredient list. A self-hosted recipe manager takes a URL, strips all of that out, and keeps the actual recipe — on your own server, in a format you can search, tag, plan meals with, and turn into a shopping list.

They differ mostly in temperament.

Mealie

mealie.io · docs · GitHub

The friendlier of the two, and the one I'd hand to someone who just wants their recipes somewhere sane. A clean, modern interface, genuinely good URL importing, and it runs as a single container with no database to set up — you're cooking from it within about ten minutes of deciding to try it. It covers the essentials well: recipes with photos, categories and tags, a meal planner, shopping lists, and a proper API if you ever want to automate around it.

Tandoor

tandoor.dev · docs · GitHub

The more structured one, and it doesn't hide that. Tandoor is a Django application backed by Postgres, so it's two containers rather than one — and in exchange it takes food data more seriously: ingredients as real, reusable entities, unit handling and recipe scaling that hold up, and meal planning and shopping lists with more machinery behind them. If you find yourself wanting your recipe app to actually understand that 500 g of flour in one recipe is the same flour as in another, this is the one leaning that way.

Neither is a toy, and neither is obviously "better" — which is exactly why I ended up running both instead of picking from a feature list.

Deploying Mealie

One container, one volume, and you're done — this is the whole thing:

# /compose/mealie/compose.yaml
services:
  mealie:
    image: ghcr.io/mealie-recipes/mealie:latest
    container_name: mealie
    restart: unless-stopped
    command: --auth-provider simple
    environment:
      - TZ=Europe/Amsterdam
      - ALLOW_SIGNUP=false
      - BASE_URL=https://recipes.example.com
    volumes:
      - /path/to/appdata/mealie:/app/data
    ports:
      - "9925:9000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

ALLOW_SIGNUP=false matters if this is going to sit behind a Cloudflare Tunnel at any point — without it, anyone who finds the URL can create their own account. First run: visit the URL, create the admin account, start importing.

Deploying Tandoor

Tandoor needs its own Postgres instance — two services instead of one:

# /compose/tandoor/compose.yaml
services:
  tandoor_db:
    image: postgres:16-alpine
    container_name: tandoor_db
    restart: unless-stopped
    environment:
      - TZ=Europe/Amsterdam
      - POSTGRES_DB=djangodb
      - POSTGRES_USER=tandoor
      - POSTGRES_PASSWORD=change-me
    volumes:
      - /path/to/appdata/tandoor/database:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tandoor -d djangodb"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 30s

  tandoor_web:
    image: vabene1111/recipes:latest
    container_name: tandoor_web
    restart: unless-stopped
    depends_on:
      tandoor_db:
        condition: service_healthy
    environment:
      - TZ=Europe/Amsterdam
      - SECRET_KEY=generate-a-real-one-do-not-copy-this
      - ALLOWED_HOSTS=recipes2.example.com,localhost,127.0.0.1
      - DB_ENGINE=django.db.backends.postgresql
      - POSTGRES_HOST=tandoor_db
      - POSTGRES_PORT=5432
      - POSTGRES_DB=djangodb
      - POSTGRES_USER=tandoor
      - POSTGRES_PASSWORD=change-me
    volumes:
      - /path/to/appdata/tandoor/staticfiles:/opt/recipes/staticfiles
      - /path/to/appdata/tandoor/mediafiles:/opt/recipes/mediafiles
    ports:
      - "9926:80"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ || wget -qO- http://127.0.0.1/ >/dev/null"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 45s

depends_on: condition: service_healthy (not just depends_on: [tandoor_db]) is what actually matters here — a plain depends_on only waits for the database container to start, not for Postgres inside it to be ready to accept connections. Tandoor's own startup will retry, but starting clean avoids a batch of confusing errors in the log on first boot.

The trap: ALLOWED_HOSTS and a 400 that doesn't explain itself

This is the one setup detail that will cost you real time if you don't know it going in: Django (which Tandoor is built on) refuses any request whose Host header isn't in ALLOWED_HOSTS — and it does so with a plain 400 Bad Request, no mention of the actual missing hostname anywhere in the response body a browser shows you.

Every hostname you'll ever address Tandoor by needs to be in that list — not just your public domain. That includes:

  • The domain from a Cloudflare Tunnel route, if you add one
  • localhost / 127.0.0.1, for anything checking it from the same host
  • host.docker.internal, if a reverse proxy (Caddy) or another container reaches it by that name rather than the Docker network's service name

Miss one, and whatever's addressing Tandoor by that name gets a 400 with no further explanation — the fix is adding the missing name to the comma-separated ALLOWED_HOSTS list and recreating the container (an env var change needs up -d, not restart — see the Docker basics post if that distinction isn't familiar yet).

Why run both instead of picking one

Neither app is strictly better — they're built around different ideas of what a recipe manager is for:

MealieTandoor
SetupOne containerApp + database, two containers
Recipe import from a URLYes, built inYes, built in
Meal planningYesYes
Shopping listsYesYes, with more structure
Photos per recipeYesLimited
RatingsYesNo
Multi-user permissionsBasicMore granular

The honest reason to run both for a while rather than committing: the parts that matter most — does the import-from-URL parser handle the sites you actually cook from, does the mobile view work the way you actually use it in a kitchen, does the shopping list aggregate the way your household shops — are exactly the parts a feature table can't tell you. A week of actually cooking from each answers it faster than any comparison post, including this one.

If you migrate: what survives, and what doesn't

Moving recipes from Mealie to Tandoor is one-directional in practice (there's no equally mature path back), and it's lossy in specific, predictable ways worth knowing before you commit:

Survives cleanly: title, description, ingredients (quantity + unit + food), instructions, prep/cook time, servings, tags.

Does not survive:

  • Images — Tandoor's import path doesn't carry photos across; expect to re-source or re-upload them.
  • Ratings — Mealie's star ratings have no equivalent field in Tandoor.
  • Step titles — if a Mealie recipe's instructions are split into named sections ("Marinade," "Day 2"), Tandoor's step model doesn't have a title field to receive that; the text survives, the structure doesn't.
  • The category/tool split — Mealie's separate categories and tools fields don't have a clean one-to-one home in Tandoor's tagging model; expect to re-organize on the other side rather than have it map automatically.

None of this makes the migration a bad idea — it just means "migrate everything, then decide" is more honest framing than "migrate and it'll all just be there."

Checkpoint

  • Mealie reachable, admin account created, ALLOW_SIGNUP=false confirmed
  • Tandoor reachable, tandoor_web healthy (confirms the database connection worked), no 400s from a missing ALLOWED_HOSTS entry
  • Both connected to whichever remote-access method fits how you'll actually use them

If this saved you some time, a coffee keeps the lights on and the posts coming.

☕ Buy me a coffee