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 80. Do not add the separate Nginx container found in older Tandoor 1 guides. Traefik connects directly to web_recipes:80.

Changelog

DateChange
2026-07-12Initial 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 A and/or AAAA record for recipes.criticalbasics.xyz pointing to the server.
  • openssl for secret generation.
  • Root or sudo access.

Verify the shared network:

docker network inspect proxy >/dev/null

3. Directory Structure

Create a dedicated directory for the stack:

sudo mkdir -p /opt/containers/tandoor/{postgresql,mediafiles}
cd /opt/containers/tandoor

The resulting persistent layout is:

/opt/containers/tandoor/
โ”œโ”€โ”€ .env
โ”œโ”€โ”€ docker-compose.yml
โ”œโ”€โ”€ mediafiles/       # Recipe images and uploaded files
โ””โ”€โ”€ postgresql/       # PostgreSQL 16 data directory

Static 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 .env

Use 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_PASSWORD

Why 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:

  1. The external Traefik reverse proxy.
  2. 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 .env file contains both the Django signing key and the database password. Keep it at mode 0600, never commit it to Git, and include it in encrypted off-server backups.

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.9 instead of an unpinned image.
  • A database healthcheck and health-based startup dependency.
  • proxy instead 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 --quiet

7. 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 ps

The expected services are:

tandoor-db_recipes-1     postgres:16-alpine         healthy
tandoor-web_recipes-1    vabene1111/recipes:2.6.9   running

Monitor the first startup:

sudo docker compose logs -f web_recipes

Tandoor 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: DENY

These 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 djangodb

Expected output:

/var/run/postgresql:5432 - accepting connections

9.4. Confirm the Image Version

sudo docker compose images

Confirm 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_recipes

Use 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:

  1. PostgreSQL: recipes, users, meal plans, shopping lists, and settings.
  2. 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 .env

Copy 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_recipes

Restore 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.

  1. Create and copy an off-server backup.
  2. Change the pinned image tag in docker-compose.yml.
  3. 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_recipes

Do 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_recipes

On 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=2

Then recreate the application container:

sudo docker compose up -d --force-recreate web_recipes

CSRF errors or incorrect redirects

Verify:

ALLOWED_HOSTS=recipes.criticalbasics.xyz
CSRF_TRUSTED_ORIGINS=https://recipes.criticalbasics.xyz

Also 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/mediafiles

Do 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 list

The 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.

๐Ÿ“šOFFICIAL TANDOOR DOCKER DOCS ๐Ÿ“ฆTANDOOR RECIPES ON GITHUB