Self-Hosting Tandoor Recipes with Docker Compose and Traefik
Table of Contents ๐
Tandoor Recipes is a self-hosted recipe manager with meal planning, shopping lists, cookbooks, recipe imports, sharing, and a mobile-friendly Progressive Web App. It is designed for private households and small groups rather than as a public recipe website.
This guide deploys Tandoor in an existing Traefik v3 and CrowdSec Docker ecosystem. It stays close to Tandoorโs official Traefik Compose example: PostgreSQL runs as db_recipes, the application runs as web_recipes, static files use a Docker volume, and recipe images are stored in a bind-mounted mediafiles directory.
The only deliberate additions are version pinning, a PostgreSQL healthcheck, the existing proxy network, and the Traefik labels required by our stack.
| โน๏ธ TANDOOR 2 ARCHITECTURE |
Tandoor 2 includes its own Nginx server and exposes container port |
Changelog
| Date | Change |
|---|---|
| 2026-07-12 | Initial Version: Tandoor 2.6.9 with PostgreSQL 16, Traefik v3, CrowdSec, trusted-proxy configuration, and backups. |
1. Architecture
The finished deployment has only one public path:
Internet
|
| HTTPS :443
v
Traefik + CrowdSec
|
| Docker network: proxy
v
web_recipes:80 (Tandoor + built-in Nginx)
|
| Private Compose network
v
db_recipes:5432 (PostgreSQL 16)PostgreSQL is not attached to the shared proxy network and publishes no host port. Tandoor also publishes no host port; only Traefik can reach it through Docker.
This guide pins:
- Tandoor
2.6.9 - PostgreSQL
16-alpine
Pinning Tandoor makes updates deliberate and prevents an unattended latest pull from applying irreversible database migrations. Tandoor explicitly warns that database downgrades are not supported.
2. Prerequisites
You need:
- A working Traefik v3 and CrowdSec stack.
- Docker and Docker Compose.
- The external Docker network named
proxy. - A DNS
Aand/orAAAArecord forrecipes.criticalbasics.xyzpointing to the server. opensslfor secret generation.- Root or
sudoaccess.
Verify the shared network:
docker network inspect proxy >/dev/null3. Directory Structure
Create a dedicated directory for the stack:
sudo mkdir -p /opt/containers/tandoor/{postgresql,mediafiles}
cd /opt/containers/tandoorThe resulting persistent layout is:
/opt/containers/tandoor/
โโโ .env
โโโ docker-compose.yml
โโโ mediafiles/ # Recipe images and uploaded files
โโโ postgresql/ # PostgreSQL 16 data directoryStatic application assets live in the Docker volume tandoor_staticfiles. They are rebuilt by Tandoor during startup and do not contain user-created recipe data.
4. Generate Secrets
Generate two independent, shell-safe secrets:
TANDOOR_SECRET_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
echo "Tandoor secret: $TANDOOR_SECRET_KEY"
echo "Database secret: $POSTGRES_PASSWORD"The Django SECRET_KEY must be at least 50 characters and must remain stable for the lifetime of the instance. Save both values in a password manager before continuing.
5. Environment File
Create /opt/containers/tandoor/.env with restrictive permissions:
sudo install -m 0600 /dev/null .env
sudo nano .envUse the following configuration and insert the secrets generated above:
# ---------------------------------------------------------------------------
# Required Tandoor settings
# ---------------------------------------------------------------------------
SECRET_KEY=REPLACE_WITH_TANDOOR_SECRET_KEY
TZ=Europe/Berlin
ALLOWED_HOSTS=recipes.criticalbasics.xyz
CSRF_TRUSTED_ORIGINS=https://recipes.criticalbasics.xyz
# Tandoor 2 includes its own nginx. Traefik is the second trusted proxy.
TANDOOR_PORT=80
ALLAUTH_TRUSTED_PROXY_COUNT=2
ENABLE_SIGNUP=0
# PostgreSQL
DB_ENGINE=django.db.backends.postgresql
POSTGRES_HOST=db_recipes
POSTGRES_DB=djangodb
POSTGRES_PORT=5432
POSTGRES_USER=djangouser
POSTGRES_PASSWORD=REPLACE_WITH_POSTGRES_PASSWORDWhy ALLAUTH_TRUSTED_PROXY_COUNT=2?
Tandoor applies IP-based rate limits to login, signup, and password-reset requests. The request passes through two proxies before reaching Django:
- The external Traefik reverse proxy.
- Tandoorโs built-in Nginx server.
The default value of 1 accounts only for the built-in Nginx server. Behind Traefik it can produce login failures or HTTP 403 responses because Tandoor selects the wrong address from X-Forwarded-For. Therefore this deployment must use 2.
| โ ๏ธ PROTECT THE ENVIRONMENT FILE |
The |
6. Docker Compose
Create /opt/containers/tandoor/docker-compose.yml:
services:
db_recipes:
restart: always
image: postgres:16-alpine
volumes:
- ./postgresql:/var/lib/postgresql/data
env_file:
- ./.env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks:
- default
web_recipes:
restart: always
image: vabene1111/recipes:2.6.9
env_file:
- ./.env
volumes:
- staticfiles:/opt/recipes/staticfiles
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
db_recipes:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.tandoor.rule=Host(`recipes.criticalbasics.xyz`)"
- "traefik.http.routers.tandoor.entrypoints=websecure"
- "traefik.http.routers.tandoor.tls.certresolver=tls_resolver"
- "traefik.http.routers.tandoor.middlewares=security-headers@file,crowdsec-bouncer@docker"
- "traefik.http.services.tandoor.loadbalancer.server.port=80"
networks:
- default
- proxy
networks:
default:
proxy:
external: true
volumes:
staticfiles:This is intentionally close to the upstream Traefik Compose example. The important local adaptations are:
vabene1111/recipes:2.6.9instead of an unpinned image.- A database healthcheck and health-based startup dependency.
proxyinstead of the generic upstream Traefik network name.- The router, TLS resolver, security-header, and CrowdSec labels used by our existing stack.
- No
ports:section. Traefik reaches Tandoor directly over the Docker network.
Validate the merged configuration without printing the secrets:
sudo docker compose config --quiet7. Launch the Stack
Pull the pinned images and start the stack:
cd /opt/containers/tandoor
sudo docker compose pull
sudo docker compose up -d
sudo docker compose psThe expected services are:
tandoor-db_recipes-1 postgres:16-alpine healthy
tandoor-web_recipes-1 vabene1111/recipes:2.6.9 runningMonitor the first startup:
sudo docker compose logs -f web_recipesTandoor waits for PostgreSQL, applies all Django migrations, collects static files, and finally starts Gunicorn. Exit the log view with CTRL+C after you see:
Database is ready
Migrating database
Collecting static files, this may take a while...
Done
Starting gunicorn| โน๏ธ TEMPORARY 502 DURING FIRST STARTUP |
Tandoor starts its internal Nginx before database migrations and Gunicorn are ready. Traefik may therefore return HTTP 502 for a short time during the first launch. This is expected. Do not delete the database or restart repeatedly; wait for the migration and static-file steps to finish. |
8. Create the First User
Open:
https://recipes.criticalbasics.xyz/When the database contains no users, Tandoor automatically redirects to /setup/. Create the first account there. This account becomes the instance superuser.
The setting ENABLE_SIGNUP=0 does not block this one-time setup page. After the first user exists, /setup/ can no longer create another account. Create additional household members from Tandoorโs space settings using invite links.
| โ ๏ธ CREATE THE FIRST ACCOUNT PROMPTLY |
The initial setup form is publicly reachable until the first account exists. Create the superuser immediately after deployment and use a unique, strong password. |
9. Verification
9.1. HTTPS and Redirects
Check the public route:
curl -I https://recipes.criticalbasics.xyz/
curl -I http://recipes.criticalbasics.xyz/Before the first account is created, HTTPS should return 302 with location: /setup/. Plain HTTP should return a permanent redirect to HTTPS.
9.2. Security Headers
The HTTPS response should include headers such as:
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENYThese come from the shared security-headers@file Traefik middleware.
9.3. Database Health
cd /opt/containers/tandoor
sudo docker compose exec db_recipes \
pg_isready -U djangouser -d djangodbExpected output:
/var/run/postgresql:5432 - accepting connections9.4. Confirm the Image Version
sudo docker compose imagesConfirm that web_recipes uses vabene1111/recipes:2.6.9 and the database remains on PostgreSQL major version 16.
10. Optional Email Configuration
Tandoor works without SMTP, but password-reset emails require a mail server. Add the following variables to .env if needed:
EMAIL_HOST=mail.your-domain.com
EMAIL_PORT=587
EMAIL_HOST_USER=tandoor@your-domain.com
EMAIL_HOST_PASSWORD=REPLACE_WITH_MAILBOX_PASSWORD
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
DEFAULT_FROM_EMAIL="Tandoor Recipes <tandoor@your-domain.com>"Recreate the application container after changing the environment:
sudo docker compose up -d --force-recreate web_recipesUse either STARTTLS on port 587 or implicit TLS on port 465, never both EMAIL_USE_TLS=1 and EMAIL_USE_SSL=1.
11. Backups and Restore
Tandoor stores user data in two places:
- PostgreSQL: recipes, users, meal plans, shopping lists, and settings.
mediafiles: recipe images and uploaded files.
The .env file must also be preserved because it contains the stable Django secret key and database credentials.
11.1. Create a Backup
cd /opt/containers/tandoor
sudo mkdir -p backups
# Logical PostgreSQL backup
sudo docker compose exec -T db_recipes \
pg_dump -U djangouser -d djangodb -Fc \
| sudo tee "backups/tandoor-db-$(date +%F).dump" > /dev/null
# Media, Compose file, and encrypted-secret source
sudo tar -czf backups/tandoor-files-$(date +%F).tar.gz \
mediafiles docker-compose.yml .envCopy the resulting files to encrypted off-server storage. A backup that only remains on the VPS is not sufficient.
11.2. Restore the Database
Stop the web application while keeping PostgreSQL running:
cd /opt/containers/tandoor
sudo docker compose stop web_recipes
sudo docker compose exec -T db_recipes \
pg_restore -U djangouser -d djangodb \
--clean --if-exists \
< backups/tandoor-db-YYYY-MM-DD.dump
sudo docker compose start web_recipesRestore the mediafiles directory from its matching archive before starting the web service. Always test the complete restore procedure before relying on a backup strategy.
12. Updating Tandoor
Read the Tandoor release notes before every update. Database migrations can move forward automatically, but Tandoor does not support automatic database downgrades.
- Create and copy an off-server backup.
- Change the pinned image tag in
docker-compose.yml. - Pull and recreate the stack:
cd /opt/containers/tandoor
sudo docker compose down
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs -f web_recipesDo not change the PostgreSQL major version as part of a routine Tandoor update. PostgreSQL data directories are not directly compatible across major versions; use a logical dump and restore for a major database upgrade.
13. Troubleshooting
HTTP 502 after deployment
Check whether migrations and static-file collection are still running:
sudo docker compose logs --tail=200 web_recipesOn first start, wait several minutes before treating a 502 response as an error.
HTTP 403 when logging in
Confirm this exact setting in .env:
ALLAUTH_TRUSTED_PROXY_COUNT=2Then recreate the application container:
sudo docker compose up -d --force-recreate web_recipesCSRF errors or incorrect redirects
Verify:
ALLOWED_HOSTS=recipes.criticalbasics.xyz
CSRF_TRUSTED_ORIGINS=https://recipes.criticalbasics.xyzAlso confirm that the Traefik router uses the same hostname and the websecure entrypoint.
Recipe images are missing
Verify the media mount and filesystem:
sudo docker compose exec web_recipes ls -ld /opt/recipes/mediafiles
sudo ls -ld /opt/containers/tandoor/mediafilesDo not remove or replace the mediafiles directory during updates.
CrowdSec blocks a legitimate client
Inspect current decisions in the Traefik stack:
cd /opt/containers/traefik-stack
sudo docker compose exec crowdsec cscli decisions listThe normal deployment keeps CrowdSec enabled. Only remove crowdsec-bouncer@docker from the Tandoor router after confirming a persistent false positive and understanding the security tradeoff.
Conclusion
Tandoor Recipes is now running as a small, maintainable two-service stack. PostgreSQL and recipe media are persistent, the database remains private, and all public traffic passes through the existing Traefik TLS and CrowdSec security layer.
By staying close to the official Compose layout, future Tandoor documentation and release notes remain directly applicable, while version pinning, trusted-proxy configuration, tested backups, and explicit Traefik labels make the setup appropriate for a production VPS.





