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.

LayerResourceNotes
NamespacetandoorFully isolated, same pattern as Mealie
DatabaseCNPG main cluster, database tandoor, owner tandoorNew Database + user CRs in postgres namespace
Storage10Gi Longhorn PVC tandoor-dataHolds media files (/opt/recipes/mediafiles)
Deploymentvabene1111/recipes:1.6.0 (port 8080)Single replica, Recreate strategy
ServiceClusterIP on 8080Standard
TLScert-manager vault-issuerrecipes.fzymgc.house + internal SANs
IngressTraefik IngressRoute on websecureWith buffering middleware for large uploads
Secrets2 ExternalSecrets (DB + App)Vault + ClusterSecretStore
AuthOIDC via Django Allauthallauth.socialaccount.providers.openid_connect
Web serverGunicorn (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

PropertyValue
base_urlhttps://recipes.fzymgc.house
secret_keyDjango SECRET_KEY (generate new)
oidc_client_idAuthentik OIDC client ID
oidc_client_secretAuthentik OIDC client secret
oidc_config_urlhttps://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.

SettingValue
Provider typeOIDC
Client IDtandoor
Redirect URIhttps://recipes.fzymgc.house/accounts/oidc/authentik/login/callback/
Signing keyAuthentik default
Scopesopenid email profile
ApplicationBound 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
ItemImpactMitigation
No SMTP configInvitation emails, password resets fail silentlyAcceptable for single-user bake-off; local auth is break-glass only
/system/ health HTML-onlyProbes detect responsiveness, not deep healthAdequate for bake-off; production would need JSON health endpoint
Gunicorn serves static/mediaSuboptimal vs nginx; warning on /system/ pageFunctional; Traefik buffering mitigates upload impact
collectstatic on every bootAdds ~5-10s to startupReadiness probe initial delay accounts for this
SOCIALACCOUNT_LOGIN_ON_GET unverifiedUsers see SSO confirmation page on first loginMinor UX; acceptable for bake-off
CSRF config unverifiedPOST may 403 if CSRF_TRUSTED_ORIGINS not auto-derivedMust be tested on first deploy; fix if needed
AspectMealieTandoor
Imageghcr.io/mealie-recipes/mealie:v3.17.0vabene1111/recipes:1.6.0
Port90008080
Auth libNative OIDCDjango Allauth 65.x
Auth env varsFlat: OIDC_CLIENT_ID, etc.Nested: SOCIALACCOUNT_PROVIDERS JSON
Health check/api/app/about (JSON)/system/ (HTML page)
DB env varPOSTGRES_URL_OVERRIDEDATABASE_URL
Web serverUvicornGunicorn
Static filesBundled in app/opt/recipes/staticfiles (ephemeral, collected on boot)
Media files/app/data/opt/recipes/mediafiles
Worker configUVICORN_WORKERS=1N/A (Gunicorn default)
Upload middlewareNot neededBuffering middleware for image uploads