Self-Hosted Recipe Manager: Mealie and Tandoor Side by Side
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_HOSTSmismatch 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
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 hosthost.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:
| Mealie | Tandoor | |
|---|---|---|
| Setup | One container | App + database, two containers |
| Recipe import from a URL | Yes, built in | Yes, built in |
| Meal planning | Yes | Yes |
| Shopping lists | Yes | Yes, with more structure |
| Photos per recipe | Yes | Limited |
| Ratings | Yes | No |
| Multi-user permissions | Basic | More 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
categoriesandtoolsfields 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=falseconfirmed - Tandoor reachable,
tandoor_webhealthy (confirms the database connection worked), no 400s from a missingALLOWED_HOSTSentry - 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