Skip to content

Tandoor Recipes Deployment Design

Date: 2026-05-09 Purpose: Deploy Tandoor Recipes alongside Mealie for a feature/UX/performance bake-off comparison.

Layer Resource Notes
Namespace tandoor Fully isolated, same pattern as Mealie
Database CNPG main cluster, database tandoor, owner tandoor New Database + user CRs in postgres namespace
Storage 10Gi Longhorn PVC tandoor-data Holds media files (/opt/recipes/mediafiles)
Deployment vabene1111/recipes:1.6.0 (port 8080) Single replica, Recreate strategy
Service ClusterIP on 8080 Standard
TLS cert-manager vault-issuer recipes.fzymgc.house + internal SANs
Ingress Traefik IngressRoute on websecure With buffering middleware for large uploads
Secrets 2 ExternalSecrets (DB + App) Vault + ClusterSecretStore
Auth OIDC via Django Allauth allauth.socialaccount.providers.openid_connect
Web server Gunicorn (standalone, no nginx) Static + media files served by Gunicorn; collectstatic runs on boot

Design decision: The official Tandoor Docker Compose uses a separate nginx container for static/media serving. In this single-container k8s deployment, Gunicorn serves everything. This triggers a cosmetic warning on the /system/ page (“Gunicorn serving media files”), but is fully functional. Set GUNICORN_MEDIA=1 to acknowledge this configuration.

argocd/app-configs/tandoor/
namespace.yaml
deployment.yaml
service.yaml
certificate.yaml
ingress.yaml
middleware.yaml
secrets.yaml
kustomization.yaml
argocd/app-configs/cnpg/
db-tandoor.yaml (new Database CR)
users-tandoor.yaml (new ExternalSecret for credentials)
  • Image: vabene1111/recipes:1.6.0 (NOT ghcr.io; version tag pinned)
  • Port: 8080 (TANDOOR_PORT)

Tandoor runs Gunicorn directly (not uWSGI, not bundled nginx). The official Docker Compose uses a separate nginx container, but for this single-container deployment:

  • boot.sh runs collectstatic on startup, populating /opt/recipes/staticfiles
  • Gunicorn workers serve both the Django app and static/media files
  • Traefik buffering middleware absorbs large uploads before forwarding to Gunicorn
  • GUNICORN_MEDIA=1 explicitly acknowledges the media-serving configuration

Implication: The /system/ health page will show a non-critical “Gunicorn serving media” warning. Gunicorn workers handle all traffic, so the buffering middleware is important — it prevents workers from being tied up during slow uploads.

Database (from tandoor-db-secrets ExternalSecret):

  • DATABASE_URL (from Vault secret, db-url key) — primary; engine derived from URL scheme
  • DB_ENGINE=django.db.backends.postgresql (static fallback if DATABASE_URL absent)

Core (static):

  • ALLOWED_HOSTS=recipes.fzymgc.house,recipes.k8s.fzymgc.house
  • TZ=America/Los_Angeles
  • DEBUG=0
  • ENABLE_SIGNUP=1 (required — defaults to False, without it no SSO users can be created)
  • GUNICORN_MEDIA=1 (acknowledges Gunicorn serving media; suppresses uncertainty on /system/)

OIDC (static + Vault):

  • SOCIAL_PROVIDERS=allauth.socialaccount.providers.openid_connect (static)
  • SOCIALACCOUNT_PROVIDERS — inline JSON containing ALL OIDC config including client_id, secret, server_url, and PKCE setting (see below)
  • SOCIAL_DEFAULT_ACCESS=1 (auto-grant space access to new SSO users)
  • SOCIAL_DEFAULT_GROUP=1 (auto-assign default group)

Note: SOCIALACCOUNT_LOGIN_ON_GET, SOCIALACCOUNT_EMAIL_AUTHENTICATION, and SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT are not exposed as flat env vars by Tandoor’s settings.py. These are omitted until verified as readable. Users will see a confirmation page on first SSO login — acceptable for the bake-off.

CSRF:

CSRF configuration behind Traefik HTTPS must be verified during deployment. Tandoor sets SECURE_PROXY_SSL_HEADER correctly. If POST requests (recipe creation, image uploads) fail with 403, CSRF_TRUSTED_ORIGINS must be added. Tandoor may auto-derive this from ALLOWED_HOSTS (needs runtime verification).

  • Endpoint: /system/
  • Returns HTML page with human-readable “Ok”/“Warning” badges; Kubernetes only checks HTTP status (200 = healthy)
  • Limitation: A 200 response means “process is responsive,” not “fully healthy.” Degraded states (unapplied migrations, broken DB) may still pass probes. For this bake-off deployment, this is acceptable.
  • boot.sh runs collectstatic on every start — add 15s to the readiness initial delay to account for this
  • Requests: 1Gi memory, 1000m CPU
  • Limits: 2Gi memory, 2000m CPU

Path: secret/fzymgc-house/cluster/tandoor

Property Value
base_url https://recipes.fzymgc.house
secret_key Django SECRET_KEY (generate new)
oidc_client_id Authentik OIDC client ID
oidc_client_secret Authentik OIDC client secret
oidc_config_url https://auth.fzymgc.house/application/o/tandoor/.well-known/openid-configuration

CNPG user: secret/fzymgc-house/cluster/postgres/users/main-tandoor

Produces db-url: postgresql://{{ .username }}:{{ .password }}@main-rw.postgres.svc.cluster.local:5432/tandoor

Produces base_url, secret_key, enable_signup, gunicorn_media, and socialaccount_providers (inline JSON):

{"openid_connect":{"OAUTH_PKCE_ENABLED":true,"APPS":[{"provider_id":"authentik","name":"Authentik","client_id":"{{ .oidc_client_id }}","secret":"{{ .oidc_client_secret }}","settings":{"server_url":"{{ .oidc_config_url }}"}}]}}

Critical: The JSON must be on a single line. ExternalSecret template handles this naturally. The provider_id (authentik) determines the OIDC callback path (see Authentik section below).

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: upload-buffer
namespace: tandoor
spec:
buffering:
maxRequestBodyBytes: 268435456 # 256Mi
memRequestBodyBytes: 10485760 # 10Mi

Referenced by IngressRoute. Buffers entire request body at Traefik before forwarding to Gunicorn, preventing worker exhaustion during large image uploads.

Setting Value
Provider type OIDC
Client ID tandoor
Redirect URI https://recipes.fzymgc.house/accounts/oidc/authentik/login/callback/
Signing key Authentik default
Scopes openid email profile
Application Bound to provider, assigned to outpost

Critical: The callback path is /accounts/oidc/<provider_id>/login/callback/ where <provider_id> matches the provider_id field in SOCIALACCOUNT_PROVIDERS JSON (authentik). In django-allauth >= 0.50.0, this segment is not optional — omitting it causes a 404 on callback and broken authentication.

db-tandoor.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: tandoor
namespace: postgres
spec:
cluster:
name: main
ensure: present
name: tandoor
owner: tandoor
users-tandoor.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: main-tandoor-credentials
namespace: postgres
spec:
# Standard pattern from users-mealie.yaml
# Vault path: fzymgc-house/cluster/postgres/users/main-tandoor
Item Impact Mitigation
No SMTP config Invitation emails, password resets fail silently Acceptable for single-user bake-off; local auth is break-glass only
/system/ health HTML-only Probes detect responsiveness, not deep health Adequate for bake-off; production would need JSON health endpoint
Gunicorn serves static/media Suboptimal vs nginx; warning on /system/ page Functional; Traefik buffering mitigates upload impact
collectstatic on every boot Adds ~5-10s to startup Readiness probe initial delay accounts for this
SOCIALACCOUNT_LOGIN_ON_GET unverified Users see SSO confirmation page on first login Minor UX; acceptable for bake-off
CSRF config unverified POST may 403 if CSRF_TRUSTED_ORIGINS not auto-derived Must be tested on first deploy; fix if needed
Aspect Mealie Tandoor
Image ghcr.io/mealie-recipes/mealie:v3.17.0 vabene1111/recipes:1.6.0
Port 9000 8080
Auth lib Native OIDC Django Allauth 65.x
Auth env vars Flat: OIDC_CLIENT_ID, etc. Nested: SOCIALACCOUNT_PROVIDERS JSON
Health check /api/app/about (JSON) /system/ (HTML page)
DB env var POSTGRES_URL_OVERRIDE DATABASE_URL
Web server Uvicorn Gunicorn
Static files Bundled in app /opt/recipes/staticfiles (ephemeral, collected on boot)
Media files /app/data /opt/recipes/mediafiles
Worker config UVICORN_WORKERS=1 N/A (Gunicorn default)
Upload middleware Not needed Buffering middleware for image uploads