Tandoor Recipes Deployment Design
Date: 2026-05-09 Purpose: Deploy Tandoor Recipes alongside Mealie for a feature/UX/performance bake-off comparison.
Architecture
Section titled “Architecture”| 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. SetGUNICORN_MEDIA=1to acknowledge this configuration.
Files to Create
Section titled “Files to Create”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)Deployment Configuration
Section titled “Deployment Configuration”Container
Section titled “Container”- Image:
vabene1111/recipes:1.6.0(NOT ghcr.io; version tag pinned) - Port: 8080 (
TANDOOR_PORT)
Web Server Architecture
Section titled “Web Server Architecture”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.shrunscollectstaticon 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=1explicitly 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.
Environment Variables
Section titled “Environment Variables”Database (from tandoor-db-secrets ExternalSecret):
DATABASE_URL(from Vault secret,db-urlkey) — primary; engine derived from URL schemeDB_ENGINE=django.db.backends.postgresql(static fallback ifDATABASE_URLabsent)
Core (static):
ALLOWED_HOSTS=recipes.fzymgc.house,recipes.k8s.fzymgc.houseTZ=America/Los_AngelesDEBUG=0ENABLE_SIGNUP=1(required — defaults toFalse, 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, andSOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECTare 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).
Health Checks
Section titled “Health Checks”- 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.shrunscollectstaticon every start — add 15s to the readiness initial delay to account for this
Resources
Section titled “Resources”- Requests: 1Gi memory, 1000m CPU
- Limits: 2Gi memory, 2000m CPU
Vault Secrets
Section titled “Vault Secrets”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
ExternalSecrets
Section titled “ExternalSecrets”tandoor-db-secrets
Section titled “tandoor-db-secrets”Produces db-url: postgresql://{{ .username }}:{{ .password }}@main-rw.postgres.svc.cluster.local:5432/tandoor
tandoor-app-secrets
Section titled “tandoor-app-secrets”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).
Traefik Middleware
Section titled “Traefik Middleware”apiVersion: traefik.io/v1alpha1kind: Middlewaremetadata: name: upload-buffer namespace: tandoorspec: buffering: maxRequestBodyBytes: 268435456 # 256Mi memRequestBodyBytes: 10485760 # 10MiReferenced by IngressRoute. Buffers entire request body at Traefik before forwarding to Gunicorn, preventing worker exhaustion during large image uploads.
Authentik Configuration
Section titled “Authentik Configuration”| 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 theprovider_idfield inSOCIALACCOUNT_PROVIDERSJSON (authentik). In django-allauth >= 0.50.0, this segment is not optional — omitting it causes a 404 on callback and broken authentication.
CNPG Database
Section titled “CNPG Database”apiVersion: postgresql.cnpg.io/v1kind: Databasemetadata: name: tandoor namespace: postgresspec: cluster: name: main ensure: present name: tandoor owner: tandoorapiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: main-tandoor-credentials namespace: postgresspec: # Standard pattern from users-mealie.yaml # Vault path: fzymgc-house/cluster/postgres/users/main-tandoorKnown Limitations
Section titled “Known Limitations”| 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 |
Differences from Mealie
Section titled “Differences from Mealie”| 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 |