Environment Variables¶
All configuration parameters for the Kamerplanter backend are controlled via environment variables. Variables are loaded by pydantic-settings — case sensitivity is not relevant.
Local configuration
For the Docker Compose environment, add all values to a .env file in the repository root directory. A template is provided as .env.example:
Database Connection¶
| Variable | Default | Required | Description |
|---|---|---|---|
ARANGODB_HOST | localhost | Yes | Hostname or IP address of the ArangoDB instance |
ARANGODB_PORT | 8529 | No | TCP port of ArangoDB |
ARANGODB_SCHEME | http | No | https for an ArangoDB that serves TLS itself (external or managed instance); any other value refuses to start |
ARANGODB_DATABASE | kamerplanter | Yes | Name of the target database |
ARANGODB_USERNAME | root | Yes | Database user |
ARANGODB_PASSWORD | — | Yes | Password for the database user |
ARANGO_ROOT_PASSWORD | — | Yes* | Root password for the ArangoDB container (Docker only) |
*ARANGO_ROOT_PASSWORD is passed directly to the ArangoDB container and is required to start the database.
Production passwords
Never use the default value rootpassword in production environments. Generate secure passwords: openssl rand -hex 32
Cache and Task Queue¶
| Variable | Default | Required | Description |
|---|---|---|---|
REDIS_URL | redis://localhost:6379/0 | Yes | Connection URL for Redis or Valkey (Celery broker and backend cache) |
WORKER_READY_FILE | — | No | Celery worker only. Empty (the default, Docker Compose) changes nothing. Set — the Helm chart sets /tmp/worker-ready — the worker checks its ArangoDB login at start, keeps trying for up to 60 seconds and exits when it does not succeed; it then takes no task. Once its consumer runs it writes the file, on shutdown it removes it. The chart's startup and readiness probes test it. |
Format: redis://[user]:[password]@[host]:[port]/[db]
Examples:
redis://localhost:6379/0 # Local without auth
redis://:mypassword@redis:6379/0 # With password
rediss://user:pass@redis-host:6380/1 # TLS (rediss://)
Security and Authentication¶
| Variable | Default | Required | Description |
|---|---|---|---|
JWT_SECRET_KEY | change-me-in-production-... | Yes | Secret key for JWT signing (HS256) |
JWT_ALGORITHM | HS256 | No | JWT signature algorithm |
ACCESS_TOKEN_EXPIRE_MINUTES | 15 | No | JWT access token validity in minutes |
REFRESH_TOKEN_EXPIRE_DAYS | 30 | No | Refresh token validity in days |
SESSION_TOKEN_EXPIRE_HOURS | 24 | No | Validity of server-side session tokens, in hours. |
FERNET_KEY | — | Yes | Fernet key for encrypting OIDC provider secrets and integration tokens. Required regardless of whether OIDC is used — the startup gate refuses to start in production when this is empty (AP-4, INF-S5). Must be a valid Fernet key: 32 bytes, url-safe base64-encoded (44 characters) — generate e.g. with python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())". The celery worker now checks the same gate at start-up and also refuses to start with DEBUG=false when the value is missing or not a valid Fernet key — the backend and the celery worker must use the same key. Decrypting a stored value with a different key now fails loudly (SecretKeyMismatchError) instead of silently handing the ciphertext to the integration as its credential. The Home Assistant token, the Pl@ntNet key and users' Apprise URLs are encrypted too (#2113): after a key change they can no longer be read and must be entered again. |
REQUIRE_EMAIL_VERIFICATION | true | No | Require email verification at registration. An installation without outbound mail sets false explicitly |
REGISTRATION_MODE | open | No | Who may create an account (local and first OIDC sign-in): open (anybody), invite_only (only with a pending email invitation for the address), closed (nobody, not even with an invitation). Existing accounts sign in in every mode. An unknown value refuses startup |
REGISTRATION_ALLOWED_DOMAINS | — (empty) | No | Optional comma-separated email domains (e.g. club.example,garden.example), exact, no subdomains. Empty = any domain. An email invitation for the address is the exception; through OIDC only an address the provider verified counts |
TENANT_MAX_MEMBERS_CEILING | 50 | No | Platform ceiling of every tenant's member limit (at least 1). The effective limit is min(max_members, TENANT_MAX_MEMBERS_CEILING); a max_members above it is refused (422), a new organization created without one takes this value. A full tenant refuses further joins with 422 MEMBER_LIMIT_REACHED. Lowering it removes nobody |
TENANT_MAX_SERVICE_ACCOUNTS | 20 | No | How many active service accounts a tenant may hold (at least 1). Beyond it, creating one answers 422 SERVICE_ACCOUNT_LIMIT_REACHED. A service account also takes a seat of the member limit. Lowering it removes none |
TENANT_MAX_ORGANIZATIONS_PER_USER | 10 | No | How many organizations one account may own, counted in every state but deleted (at least 0). Beyond it, POST /tenants answers 422 ORGANIZATION_LIMIT_REACHED. 0 means no account founds an organization itself. A limit of the account, not a plan quota |
TENANT_INVITATION_EMAILS_PER_DAY | 50 | No | How many email invitations one account may send in 24 hours, across all its tenants (at least 1). Every email invitation goes to an address the inviter chooses freely; beyond it POST /api/v1/tenants/{slug}/invitations/email answers 429 RATE_LIMIT_EXCEEDED, and nothing is created or sent. Counted from the stored invitations (issue #2162) |
TENANT_EXPORTS_PER_DAY | 3 | No | How many tenant exports one tenant may request in 24 hours, every state counted, failed ones too (at least 1). Beyond it, POST /api/v1/tenants/{slug}/exports answers 429 RATE_LIMIT_EXCEEDED. See Exporting and importing a tenant (issue #2138) |
TENANT_IMPORT_MAX_UPLOAD_MB | 512 | No | Largest tenant archive in MiB that POST /api/v1/admin/platform/tenant-imports accepts (at least 1); whatever it is set to, it may unpack to at most 1 GiB. Beyond it 413 (issue #2138). This route's own body limit (+ 1 MiB) — every other route keeps STORAGE_MAX_FILE_SIZE_MB + 1 MiB — and capped at UPLOAD_SPOOL_BUDGET_MB, because the upload is spooled to /tmp before the route runs; with the defaults about 51 MiB. Helm: tenantExport.importMaxUploadMb. Sizing: Archive size and /tmp |
TENANT_EXPORT_SIGNING_SECRET | — (empty) | No | Secret tenant archives are signed and checked with (HMAC-SHA256 over the manifest). Empty: derived from JWT_SECRET_KEY as before. Set: at least 32 characters (openssl rand -hex 32), otherwise the backend does not start; archives signed before import as unverified. Backend and Celery worker need the same value; Helm reads it optionally from tenantExport.signingSecret (default kamerplanter-secrets, key TENANT_EXPORT_SIGNING_SECRET). Never in values.yaml (issue #2138) |
RATE_LIMIT_TENANT_IMPORT | 10/hour | No | Rate limit of POST /api/v1/admin/platform/tenant-imports, per account (issue #2138) |
HIBP_ENABLED | false | No | Enable "Have I Been Pwned" check on password change |
COOKIE_SECURE | true | No | Sets the Secure flag on the refresh-token cookie. Only set to false for plain-HTTP E2E test environments without TLS — always leave true in production. |
With plans (issue #2142), TENANT_MAX_MEMBERS_CEILING, TENANT_MAX_SERVICE_ACCOUNTS, STORAGE_TENANT_QUOTA_MB, STORAGE_MAX_PHOTOS_PER_INSTANCE and the three AI_BUDGET_* are the value a plan with an empty quota takes — on the default plan standard, for every tenant. A plan may differ; only TENANT_MAX_MEMBERS_CEILING stays a hard ceiling. See API reference: plans and quotas.
Change JWT_SECRET_KEY in production
The default value change-me-in-production-use-openssl-rand-hex-32 must not be used in production. Generate a secure value:
JWT_SECRET_KEY invalidates all active tokens — all users will be logged out. Privacy & GDPR (REQ-025 / NFR-011)¶
These variables control the legally mandated deletion/anonymization of personal data (see Privacy (GDPR)) and are independent of the operating mode — they apply in both Light and Full mode.
| Variable | Default | Required | Description |
|---|---|---|---|
ERASURE_TOMBSTONE_SALT | — | Yes | High-entropy secret (at least 32 characters) used to pseudonymize deleted user accounts (tombstone hash, erasure request key and tenant slug digest, NFR-011 §4). This value must never change once accounts have been erased. The startup gate refuses to start in production if this value is empty or shorter than 32 characters — regardless of operating mode. Generate with openssl rand -hex 32. |
LOG_PSEUDONYM_SALT | — | Yes | High-entropy secret (at least 32 characters) that keys only the log pseudonyms: the subject= references (sub_…) and the email_sha256 digests on log lines, plus the requested_by_subject provenance field on erasure and tenant-erasure records (NFR-011 §3.4). Kept separate from ERASURE_TOMBSTONE_SALT because — unlike the tombstone salt — this value may be rotated. The startup gate refuses to start in production for both the backend and the Celery worker if this value is empty or shorter than 32 characters — regardless of operating mode. Generate with openssl rand -hex 32; should differ from ERASURE_TOMBSTONE_SALT (not enforced). |
PRIVACY_DATA_CONTROLLER_NAME | Kamerplanter Operator | No | Name of the data controller, shown in export and disclosure documents. |
PRIVACY_DATA_CONTROLLER_EMAIL | privacy@kamerplanter.example | No | Contact email of the controller for GDPR requests. |
RETENTION_SOFT_DELETE_RETENTION_DAYS | 90 | No | Grace period before an account marked for deletion (soft-deleted) is permanently (hard-)deleted (NFR-011 R-01). Minimum: 1, maximum: 90. The older name PRIVACY_HARD_DELETE_AFTER_DAYS is still accepted; if both are set, the new name wins. |
RETENTION_UNVERIFIED_ACCOUNT_DAYS | 7 | No | Number of days after registration after which a never-confirmed account is automatically deleted (NFR-011 R-02). Minimum: 1, maximum: 7. |
RETENTION_UNVERIFIED_LOCAL_REAP_ENABLED | false | No | Release switch for R-02: when false the cleanup only counts locally registered, never-confirmed accounts (dry run, local_registrations_pending); when true it deletes them too. Set it only after you have seen the counted number (NFR-011 R-02). |
RETENTION_IP_ANONYMIZATION_DAYS | 7 | No | Number of days after a session was issued after which its IP address is anonymized (IPv4: last octet → 0; NFR-011 R-03). Minimum: 1, maximum: 7. Covers session IP addresses only, not the IP address of a consent record. |
RETENTION_EXPORT_FILE_RETENTION_HOURS | 72 | No | How long a generated data export (Art. 15/20 GDPR) is kept before the file is automatically deleted (NFR-011 R-05). Minimum: 1, maximum: 72. The older name PRIVACY_EXPORT_RETENTION_HOURS is still accepted; if both are set, the new name wins. |
RETENTION_ERASURE_AUDIT_RETENTION_YEARS | 3 | No | Number of years a completed erasure request (erasure_requests, status=completed) is kept as the Art. 5(2) GDPR accountability proof before it is permanently deleted (NFR-011 R-06). Counted in calendar years. Minimum: 1, maximum: 3. |
RETENTION_EMAIL_CHANGE_RETENTION_HOURS | 24 | No | Validity of the confirmation link when changing an email address (NFR-011 R-07); after that the request is set to expired, no hard-delete happens. Minimum: 1, maximum: 24. The older name PRIVACY_EMAIL_CHANGE_TTL_HOURS is still accepted; if both are set, the new name wins. |
RETENTION_EMAIL_CHANGE_REVERT_DAYS | 7 | No | Validity of the undo link the previous address receives after a confirmed email change (NFR-011 R-07a). After that, the same hourly retention task as R-07 clears the previous_email field and the undo token's hash. Minimum: 1, maximum: 7. |
RETENTION_CONSENT_RETENTION_YEARS | 3 | No | Years after revocation after which a consent record is permanently deleted (NFR-011 R-04). Minimum: 1, maximum: 3. A higher value stops backend and worker from starting. |
RETENTION_CONSENT_IP_ANONYMIZATION_DAYS | 7 | No | Days after recording after which the IP address of a consent record is anonymized (NFR-011 R-04a). Minimum: 1, maximum: 7. A higher value stops backend and worker from starting. |
RETENTION_INVITATION_RETENTION_DAYS | 30 | No | Days after expiry after which an expired invitation is permanently deleted (NFR-011 R-12). Minimum: 1, maximum: 30. A higher value stops backend and worker from starting. |
RETENTION_ERASURE_MEMBER_NOTICE_DAYS | 7 | No | Days the final deletion of an account with a shared personal garden waits after the other members were notified, when they were notified only after the deletion request was scheduled (a request from before #1824, or a failed first email; NFR-011 R-01a). An administrator's immediate deletion never waits. Minimum: 1, maximum: 7. A higher value stops backend and worker from starting. |
RETENTION_TENANT_ERASURE_GRACE_DAYS | 90 | No | Days an accepted tenant deletion is postponed (NFR-011 R-01b). During this time the tenant is closed, its members have been told the date, and its management can cancel the deletion. 0 deletes at once (self-hosted, one household). Minimum: 0, maximum: 90. A higher value stops backend and worker from starting. |
RETENTION_HARVEST_DATA_MIN_RETENTION_YEARS | 5 | No | Number of years after harvest_date after which the harvest data a tenant deletion retained (harvest, quality assessment, yield metric) is permanently deleted (NFR-011 R-16). Minimum: 5 (statutory minimum under the CanG). |
RETENTION_TREATMENT_MIN_RETENTION_YEARS | 3 | No | The same for treatment applications, counted from applied_at (NFR-011 R-17). Minimum: 3 (PflSchG §11). |
RETENTION_INSPECTION_MIN_RETENTION_YEARS | 3 | No | The same for inspection records, counted from inspected_at (NFR-011 R-18). Minimum: 3 (PflSchG §11). |
A value above the maximum aborts API and worker start (the message names the variable and the limit, never the value). The maximum is the NFR-011 value: periods may be shortened for data minimisation but not lengthened. Setting an older PRIVACY_* name logs a warning; setting both names to different values logs an error (the RETENTION_* value wins).
For RETENTION_SOFT_DELETE_RETENTION_DAYS, RETENTION_EXPORT_FILE_RETENTION_HOURS and RETENTION_EMAIL_CHANGE_RETENTION_HOURS, the older PRIVACY_* names were already documented but had no effect — the code used fixed values. They now actually work and remain valid as aliases in addition.
ERASURE_TOMBSTONE_SALT — a boot blocker in production, must never change
Unlike most other variables on this page, ERASURE_TOMBSTONE_SALT is not an optional feature flag: in production (DEBUG=false) the backend simply refuses to start when this value is missing or too short — regardless of whether GDPR erasure requests are actively used. This salt keys the tombstone hash of erased accounts, the erasure request key and the tenant slug digest — never the log pseudonyms (that is LOG_PSEUDONYM_SALT's job). It must never change once accounts have been erased, or existing tombstones become unreadable. The Celery worker also exits at start-up when the salt is missing or too short; give it the same value and the same DEBUG setting as the backend.
With DEBUG=true the backend starts without a valid value, for example in the local development stack. Account erasure still refuses every run before it touches anything: when a platform admin deletes an account, the backend answers 503. An Art. 17 erasure request is still accepted, but the daily run does not carry it out; the request stays open as partially_completed until you set a valid salt. For a full list of unconditionally required secrets, see Configuration Matrix — Mandatory Secrets per Enabled Feature.
LOG_PSEUDONYM_SALT — a boot blocker in production, same value for backend and worker
LOG_PSEUDONYM_SALT is also not an optional feature flag: both the backend and the Celery worker refuse to start in production (DEBUG=false) when this value is missing or shorter than 32 characters. It keys only the log pseudonyms — the subject= reference and the email_sha256 digest on log lines, plus the requested_by_subject provenance field on erasure and tenant-erasure records. Give both processes the same value. With DEBUG=true both start without a valid value, but account and tenant erasure then refuse every run (503), because their proof would otherwise carry no reference to the account that triggered it.
Rotation. Unlike ERASURE_TOMBSTONE_SALT, this salt may be changed: set the new value in both the backend and the worker (Kubernetes secret kamerplanter-secrets, or .env for Docker Compose) and restart both. After that, log lines and requested_by_subject values from before the change no longer correlate with later ones — someone holding only the new salt cannot map old references to an account. Which admin account triggered a stored erasure can then only be recomputed with the old salt — keep it safely stored for as long as the erasure proof is kept (one year) if that attribution must stay verifiable. No lookup depends on the log salt, so rotation has no other side effects. The value should differ from ERASURE_TOMBSTONE_SALT; this is not enforced.
Operating Mode¶
| Variable | Default | Required | Description |
|---|---|---|---|
KAMERPLANTER_MODE | full | No | Operating mode: full (auth + tenants) or light (no auth, local single-user) |
APPRISE_ALLOW_PRIVATE_TARGETS | depends on the mode: false in full, true in light | No | Whether Apprise targets (Gotify, Matrix, ntfy servers) may sit in private networks: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, fc00::/7. Checked when saving and when sending. Unset, KAMERPLANTER_MODE decides; true or false overrides it in either direction. In full mode a private target reaches the services in the cluster (API server, ArangoDB, Valkey), hence off. Unlike HA_ALLOW_PRIVATE_ENDPOINT, any user enters these addresses. DEBUG does not loosen the rule; loopback, link-local and cloud metadata stay refused in every case. See Helm: egress for notification channels. |
DEBUG | false | No | Enable debug logging (verbose — never use in production). Also disables the startup gate for production secrets — never set this in production. |
FRONTEND_URL | http://localhost:5173 | No | Frontend URL (used for email links) |
APP_BASE_URL | http://localhost:5173 | No | Base URL for QR codes on plant labels (print views, see Print & Export). Set to the publicly reachable frontend URL in production, otherwise printed QR codes point to localhost. Also forms the callback URL of the OAuth/OIDC sign-in and of the fresh sign-in that confirms a step-up ({APP_BASE_URL}/api/v1/auth/oauth/{slug}/callback) — register this URL with the identity provider. Also forms the iCal feeds' subscription URL (ical_url, REQ-015, #2226); there an http:// value is raised to https://, except for localhost, 127.0.0.1 and ::1. The value should start with http:// or https:// and name a host; otherwise the backend logs the warning app_base_url_not_absolute_http at start, and only the subscription URL adds the scheme itself. |
Light Mode (KAMERPLANTER_MODE=light)¶
In Light Mode, token authentication is disabled. The API is usable without login. This mode is intended for local single-user installations that are not exposed to the internet.
Do not expose Light Mode publicly
Light Mode disables all authentication layers. Never run it with a publicly accessible port.
Network exposure under Docker Compose¶
This variable is read by Docker Compose (docker-compose.yml and docker-compose.release.yml) at start-up, not by the backend.
| Variable | Default | Required | Description |
|---|---|---|---|
KAMERPLANTER_BIND_ADDRESS | 127.0.0.1 | No | Host address Compose binds the user interface (8080) and the API (8000) to. With 0.0.0.0, other devices on the network reach both ports — see Permanent operation — Accessing from other devices. The databases, Valkey and Ollama stay bound to 127.0.0.1 regardless. |
CORS Configuration¶
| Variable | Default | Required | Description |
|---|---|---|---|
CORS_ORIGINS | ["http://localhost:3000","http://localhost:5173"] | No | JSON array of allowed origins for CORS |
Format: Always as a JSON array in string format:
Email¶
| Variable | Default | Required | Description |
|---|---|---|---|
EMAIL_ADAPTER | console | No | Email adapter: console (output to log, link only with DEBUG=true), smtp or resend. Any other value — for example a typo — is rejected at startup: the API and the worker refuse to start rather than silently falling back to console. |
SMTP_HOST | localhost | No | SMTP server hostname |
SMTP_PORT | 587 | No | SMTP port |
SMTP_USERNAME | — | No | SMTP username |
SMTP_PASSWORD | — | No | SMTP password |
SMTP_FROM_EMAIL | noreply@kamerplanter.example | No | Sender address for system emails sent via SMTP |
SMTP_USE_TLS | true | No | Enable STARTTLS for SMTP |
RESEND_API_KEY | — | Conditional | API key for sending via Resend (secret). Required when EMAIL_ADAPTER=resend is set — without it the application refuses to start. Provide it through the Kubernetes Secret kamerplanter-secrets or your .env file, never through a Helm values file. |
RESEND_FROM_EMAIL | noreply@kamerplanter.example | No | Sender address for system emails sent via Resend. This address's domain must be verified in the Resend account, otherwise sending fails. |
In development mode (EMAIL_ADAPTER=console), emails are not sent but printed to the backend log. That log line carries the verification or password-reset link only when DEBUG=true is also set — its token takes over the account otherwise. Without DEBUG=true the line only states that no email was delivered, with no token. If the API starts with EMAIL_ADAPTER=console and DEBUG=false — the case for a production install without SMTP or Resend configured, since the Helm chart sets no adapter by default — it writes a startup warning to the log (email_adapter_console_in_production). For a production install, configure either EMAIL_ADAPTER=smtp or EMAIL_ADAPTER=resend; otherwise registration and password reset cannot be completed. Email invitations into a tenant do not go out either: the invitation is created, the response reports delivered: false, and the UI shows the inviter the link to pass on (issue #2162). The link points at FRONTEND_URL.
Resend: hosted email delivery over an HTTP API
EMAIL_ADAPTER=resend sends the same system emails (email verification, password reset, the step-up confirmation code, notification emails) as the SMTP adapter, but over Resend's HTTP API instead of an SMTP connection. In the logs, the recipient address only ever appears as a salted digest, never in plain text; if a send fails, the application logs only the error type and the HTTP status, never the response body or the API key. Each send attempt has a 10-second timeout.
Also used by the notification system
These variables also configure the email channel of the notification system — there is no separate SMTP configuration for notifications.
Two different requirements for federated accounts
An account without a local password with an OIDC-capable provider (Google, generic OIDC) confirms irreversible account actions and credential changes (account deletion, setting a first local password, tenant deletion, email change, and since version 1.19 also issuing an API key, device pairing, and removing a sign-in method) with a fresh sign-in at that provider — see API Documentation: Authentication. That needs the provider to support prompt=login/max_age and the auth_time claim, not SMTP. Only an account whose linked providers are exclusively GitHub and/or Apple (neither can prove a fresh sign-in) uses the emailed confirmation code instead — see Requesting a Confirmation Code by Email. If the instance runs such an account with EMAIL_ADAPTER=console and DEBUG=false, the application reports that the confirmation code cannot be delivered (503) instead of letting it silently vanish — it still cannot delete itself, set a first local password, delete a tenant, change its email address, or issue an API key or pairing code until SMTP is configured. EMAIL_ADAPTER=smtp is therefore required for production operation with GitHub/Apple-only accounts, not just recommended.
External Data Enrichment (REQ-011)¶
| Variable | Default | Required | Description |
|---|---|---|---|
PERENUAL_API_KEY | — | No | API key for Perenual plant database |
TREFLE_API_KEY | — | No | API key for Tréflé plant database |
ENRICHMENT_HTTP_TIMEOUT | 30 | No | HTTP timeout for external API requests (seconds) |
GBIF is used without an API key (public API). Perenual and Tréflé require free registration.
Knowledge Service — Re-Ranking (optional)¶
These variables configure the optional cross-encoder re-ranker of the Knowledge Service. When RERANKER_URL is empty, the Knowledge Service operates in hybrid-search-only mode (graceful degradation). See ADR-007.
| Variable | Default | Required | Description |
|---|---|---|---|
RERANKER_URL | `` (empty) | No | HTTP URL of the reranker microservice, e.g. http://reranker-service:8081. Empty = re-ranking disabled. |
RERANKER_INITIAL_K | 15 | No | Number of candidates from the Hybrid Search step that are at most re-scored by the cross-encoder (a scoring cap, not a limit on the number of results — see AI Architecture). |
RERANKER_MAX_DOCUMENT_CHARS | 500 | No | Maximum character count of title\ncontent per candidate chunk before it is sent to the reranker sidecar. The chunk handed to the LLM keeps its full text — only the reranker input is truncated. |
RERANKER_TOP_K | 5 | No | Number of chunks passed to the LLM context after re-ranking. |
RERANKER_MODEL | bge-reranker-v2-m3 | No | ONNX model name in the reranker service container (directory under /app/models/onnx/). |
RERANKER_MODEL belongs to the reranker service, not the knowledge service
RERANKER_MODEL is set as an environment variable on the reranker-service container — not on the knowledge-service. The other four variables (RERANKER_URL, RERANKER_INITIAL_K, RERANKER_MAX_DOCUMENT_CHARS, RERANKER_TOP_K) belong to the Knowledge Service.
Resource requirements and time budget
The reranker service requires 1.5–4 GB RAM (depending on the model) and adds latency per request. On bge-reranker-v2-m3 at the Helm chart's 2-CPU limit, a (query, document) pair of 512 tokens costs about 3 seconds; the sidecar aborts a rerank request server-side after 25 seconds (fallback reason deadline, see AI Architecture). A real HTTP probe over all 100 benchmark questions shows: with the defaults RERANKER_INITIAL_K=15 and RERANKER_MAX_DOCUMENT_CHARS=500 (pair median 166 tokens, max 222), all 100 requests succeeded — p50 12.9 seconds, p90 15.0 seconds, max 16.0 seconds, within the 25-second budget. A CI guard checks, for the defaults and for every deployment file in the repository that sets either variable, that RERANKER_INITIAL_K × RERANKER_MAX_DOCUMENT_CHARS ≤ 7,500 holds (RERANK_SCORED_CHARS_BUDGET in config.py) and that RERANKER_INITIAL_K is at most 50 (the sidecar's request bound). The defaults use up the budget exactly (15 × 500 = 7,500). The guard does not see values you set outside the repository. Raising either value past that product requires re-measuring latency first. For Raspberry Pi and resource-constrained environments, it is still recommended to leave RERANKER_URL empty.
AI Assistant ¶
These variables belong to the Kamerplanter backend and control the three-stage toggle mechanism as well as the connection to the Knowledge Service (see AI Assistant — User Guide). Provider selection (Ollama/Anthropic/OpenAI-compatible) is a separate configuration on the Knowledge Service itself — see AI Provider Setup.
| Variable | Default | Required | Description |
|---|---|---|---|
AI_FEATURES_ENABLED | false | No | Stage 1 of the three-stage toggle. false makes every /ai/* endpoint respond with HTTP 404 — the AI API then effectively doesn't exist. |
KNOWLEDGE_SERVICE_ENABLED | false | No | Enables the connection to the Knowledge Service (needed by both the older /api/v1/knowledge/* path and internally by the AI Assistant). |
KNOWLEDGE_SERVICE_URL | http://knowledge-service:8000 | No | Base URL of the Knowledge Service microservice. |
AI_KNOWLEDGE_SERVICE_TIMEOUT_S | 60 | No | HTTP timeout of the KnowledgeServiceAdapter against the Knowledge Service (seconds). |
AI_CIRCUIT_BREAKER_THRESHOLD | 3 | No | Number of consecutive failures after which the adapter marks the Knowledge Service unreachable. |
AI_CIRCUIT_BREAKER_WINDOW_S | 60 | No | Time window (seconds) over which failures are counted for AI_CIRCUIT_BREAKER_THRESHOLD. |
AI_CIRCUIT_BREAKER_COOLDOWN_S | 60 | No | Wait time (seconds) before the adapter allows requests to the Knowledge Service again after the circuit breaker trips. |
AI_PUBLIC_RATE_LIMIT_PER_MIN | 10 | No | IP rate limit for the knowledge question POST /api/v1/public/ai/ask (requests per minute). The endpoint exists in Light Mode only and runs as the system user there. |
AI_BUDGET_USER_CALLS_PER_DAY | 50 | No | AI calls one account may start in one tenant per UTC day (tips, daily tip, "Why?", chat message, glossary generation, AI diagnosis together). Above that 429 AI_BUDGET_EXCEEDED with Retry-After until the day ends. 0 switches the budget off. |
AI_BUDGET_TENANT_CALLS_PER_DAY | 500 | No | AI calls of all members of one tenant together per UTC day. A member refused at their own budget does not use up this one. 0 switches it off. |
AI_BUDGET_TENANT_TOKENS_PER_DAY | 2000000 | No | LLM tokens (prompt + completion, as the Knowledge Service reports them) one tenant may spend per UTC day. Checked before the next call; a single answer can overshoot it. 0 switches it off. The counters live in Valkey; when Valkey cannot be reached, no AI call runs (503 AI_BUDGET_UNAVAILABLE). |
INTERNAL_SERVICE_TOKEN | — | Conditional | Shared secret for cluster-internal M2M calls (including to the Knowledge Service). Required once KNOWLEDGE_SERVICE_ENABLED=true is set — without a token, the startup gate refuses to boot (AP-4). |
Instance-wide activation alone isn't enough
AI_FEATURES_ENABLED=true only unlocks the AI API instance-wide (stage 1). For a specific tenant (garden) to actually use AI features, tenant.settings.ai_features_enabled must additionally be set for that tenant (stage 2) — there is currently neither a UI nor a dedicated API endpoint for this, see AI Assistant — For Technical Users / Self-Hosters.
Provider configuration lives on the Knowledge Service, not the backend
LLM_PROVIDER, LLM_API_URL, LLM_API_KEY, and LLM_MODEL are environment variables of the standalone Knowledge Service deployment (src/knowledge-service/), not this backend. Details: AI Provider Setup.
MCP Server ¶
These variables control the MCP server — the tool interface through which external LLM clients (Claude Desktop, Claude Code, custom agents) can access Kamerplanter via a service-account API key.
| Variable | Default | Required | Description |
|---|---|---|---|
MCP_SERVER_ENABLED | false | No | Master switch. Until set to true, all /mcp/* endpoints answer HTTP 404 — the interface effectively does not exist. |
MCP_IDEMPOTENCY_TTL_HOURS | 24 | No | Validity window for a write tool's idempotency_key — after it expires, a repeated call is treated as a new action. |
MCP_AUDIT_RETENTION_DAYS | 90 | No | Retention window for the mcp_audit_log (NFR-011) — older entries are automatically deleted. |
MCP_MAX_IMAGE_PAYLOAD_MB | 4 | No | Ceiling on the total payload of one call to the diary tool get_diary_entry_photos, in megabytes (Base64-encoded). Exceeding it returns the error code payload.too_large instead of silently dropping photos — see MCP Server — Diary Analysis. |
No standalone process, no dedicated connection variables
The MCP server runs in-process with the existing backend and shares its ArangoDB/Redis connection — there is no separate host, port or credential configuration.
Diary — Environment Snapshot¶
When a diary entry is created, Kamerplanter reads the sensor values covering the plant and stores them alongside — never merged into — the hand-noted measurements. See Diary — The environment is recorded automatically.
| Variable | Default | Required | Description |
|---|---|---|---|
DIARY_ENVIRONMENT_CAPTURE_ENABLED | true | No | Master switch. With false, every new entry is stored marked "not attempted" — distinguishable from "we looked and found nothing". |
DIARY_ENVIRONMENT_MAX_AGE_MINUTES | 60 | No | A reading older than this is not captured at all. An entry presenting yesterday's sensor value as current is worse evidence than an entry with no climate values. |
DIARY_ENVIRONMENT_CAPTURE_TIMEOUT_SECONDS | 3.0 | No | Hard ceiling for the whole capture. When it runs out, the entry is stored with whatever arrived in time — creating an entry never waits longer on a sensor. |
A failure never blocks the entry
An unreachable Home Assistant, a missing TimescaleDB or an exhausted time budget produce an entry without (or with incomplete) environment values, never a rejected creation.
Metrics (Prometheus)¶
| Variable | Default | Required | Description |
|---|---|---|---|
METRICS_PORT | 0 | No | Port on which the backend serves /metrics in the Prometheus format. 0 starts no listener. The Helm chart sets 9464 once monitoring.enabled is on. |
METRICS_BIND_ADDRESS | 0.0.0.0 | No | Address the metrics listener binds to. |
The metrics run on a port of their own, never as a route of the API on port 8000. The path from outside (ingress → frontend nginx → backend :8000) therefore cannot reach them; inside the cluster the NetworkPolicy admits only the configured Prometheus. The series carry neither tenant nor person: handler is the route pattern (/api/v1/t/{tenant_slug}/…), never the requested path. Details: Helm — metrics.
mDNS / Zeroconf Discovery¶
| Variable | Default | Required | Description |
|---|---|---|---|
MDNS_ENABLED | false | No | Enable mDNS service announcement (_kamerplanter._tcp.local.) |
INSTANCE_ID | (auto) | No | Unique instance ID (e.g. kp-abc123). Auto-generated at startup if empty. |
When enabled, the backend announces a _kamerplanter._tcp.local. service on the local network. Home Assistant detects this service automatically and offers to set up the Kamerplanter integration.
Stable Instance ID
The INSTANCE_ID is used for duplicate detection in Home Assistant. If left empty, a new ID is generated on every restart. For stable discovery, set a fixed value, e.g. INSTANCE_ID=kp-my-server.
mDNS and Kubernetes¶
mDNS relies on Multicast UDP (port 5353) within the local Layer 2 network. In standard Kubernetes clusters, mDNS does not work because:
- Overlay network blocks multicast — Standard CNIs (Calico, Cilium, Flannel) only route L3 traffic. Multicast packets from a pod never reach the physical LAN — Home Assistant cannot see the announcements.
- Pod IP is not LAN-reachable — Even if multicast worked, the announced pod IP (e.g.
10.42.x.x) would not be reachable from outside the cluster.
| Deployment | MDNS_ENABLED | Rationale |
|---|---|---|
| Docker Compose / Bare Metal | true | Backend runs directly on the LAN — set MDNS_ENABLED=true |
K3s / MicroK8s single-node + hostNetwork: true | true | Pod shares host network — multicast reaches the LAN |
| Standard K8s Cluster | false | Overlay network blocks multicast — use manual config flow in HA as fallback |
| Cloud (AWS, GCP, Azure) | false | No local network available |
hostNetwork is a trade-off
With hostNetwork: true, the pod shares the host's network namespace. Multicast works, but at the cost of network isolation (port conflicts possible, no NetworkPolicy enforcement). Only recommended for homelab / Raspberry Pi scenarios.
The Helm chart sets MDNS_ENABLED to false by default. The manual config flow in Home Assistant (URL input) works in every deployment scenario as a fallback.
Home Assistant Integration (REQ-005)¶
| Variable | Default | Required | Description |
|---|---|---|---|
HA_URL | — | No | Home Assistant base URL, e.g. http://homeassistant.local:8123 |
HA_ACCESS_TOKEN | — | No | Long-Lived Access Token from Home Assistant |
HA_TIMEOUT | 10 | No | HTTP timeout for HA requests (seconds) |
HA_ALLOW_PRIVATE_ENDPOINT | false | No | SSRF opt-in: Home Assistant commonly runs on the LAN over HTTP at a private/RFC1918 address (homeassistant.local, 192.168.x.x) or localhost. Without this opt-in, the SSRF guard blocks connections to such addresses. The cloud-metadata / link-local range (169.254.0.0/16) is always blocked regardless of this flag. |
When both HA_URL/HA_ACCESS_TOKEN are set, the backend also enables the Home Assistant channel of the notification system (persistent notifications, mobile push, TTS).
Apprise channel requires an additional Python package
The apprise notification channel is always active regardless of the Home Assistant variables, but requires the optional apprise Python package in the backend image (pip install apprise) — there is no dedicated environment variable for it. See Notifications — Apprise for details.
Time-Series Data (TimescaleDB, REQ-005)¶
These variables enable the optional TimescaleDB connection for high-frequency sensor time-series with automatic downsampling (see Sensors). Without TIMESCALEDB_ENABLED=true, manual and automatic readings are still stored in ArangoDB — the app remains fully functional, just without automatic multi-stage downsampling.
| Variable | Default | Required | Description |
|---|---|---|---|
TIMESCALEDB_ENABLED | false | No | Master switch for the TimescaleDB connection. |
TIMESCALEDB_HOST | localhost | No | Hostname of the TimescaleDB instance. |
TIMESCALEDB_PORT | 5432 | No | TCP port. |
TIMESCALEDB_DATABASE | kamerplanter_sensors | No | Database name. |
TIMESCALEDB_USERNAME | postgres | No | Database user. |
TIMESCALEDB_PASSWORD | changeme | Conditional | Database password. Required in production — the startup gate refuses to start when TIMESCALEDB_ENABLED=true is set and this value is still the unchanged changeme (see Configuration Matrix — Mandatory Secrets per Enabled Feature). |
TIMESCALEDB_POOL_MIN_SIZE | 2 | No | Minimum connection pool size. |
TIMESCALEDB_POOL_MAX_SIZE | 10 | No | Maximum connection pool size. |
Docker Compose: dedicated profile
In the local Docker Compose environment, TimescaleDB only starts with docker-compose --profile timescaledb up -d. In Kubernetes you switch it on with controllers.timescaledb.enabled: true; the chart then sets TIMESCALEDB_ENABLED, TIMESCALEDB_HOST and the other values itself (see Helm Charts).
Environment Control & Actuators (REQ-018)¶
This variable controls the periodic evaluation of schedules and rules, the hourly override-expiry sweep, and the 5-minute online/offline sync with Home Assistant for Environment Control & Actuators.
| Variable | Default | Required | Description |
|---|---|---|---|
ACTUATOR_CONTROL_LOOP_ENABLED | false | No | Kill switch for the three periodic actuator-control tasks (evaluate_control_rules every 30 s, expire_manual_overrides hourly, sync_actuator_states every 5 min). When disabled, schedules and rules are not evaluated automatically — actuators remain controllable via the REST API at any time regardless (direct command, override, emergency stop). |
No separate HA toggle needed
Unlike the other Home Assistant features, actuator control needs no additional opt-in — as long as HA_URL/HA_ACCESS_TOKEN are set and ACTUATOR_CONTROL_LOOP_ENABLED=true, the system dispatches commands to Home Assistant actuators automatically.
InvenTree Integration (REQ-016)¶
These variables enable the optional integration with InvenTree. Without INVENTREE_ENABLED=true, every InvenTree endpoint returns a "feature disabled" error (HTTP 409) without blocking the app.
| Variable | Default | Required | Description |
|---|---|---|---|
INVENTREE_ENABLED | false | No | Kill switch for the entire InvenTree integration. |
INVENTREE_ALLOW_PRIVATE_ENDPOINT | false | No | Allows an InvenTree instance with a private/LAN address (analogous to HA_ALLOW_PRIVATE_ENDPOINT). Without this opt-in, SSRF protection blocks connections to internal addresses. |
You then set up the connection (including the API token) and links via the REST API — see Equipment & Inventory (InvenTree) — For Technical Users / Self-Hosters for details.
Weather Forecast & Frost Early-Warning ¶
These variables control the weather forecast fetching and the proactive frost early-warning built on top of it. Without WEATHER_ENABLED=true, both features stay fully disabled — sites without a configured weather source are likewise unaffected.
| Variable | Default | Required | Description |
|---|---|---|---|
WEATHER_ENABLED | false | No | Kill switch for the entire weather feature (source fetching + frost early-warning). See Weather Sources per Location for the actual source configuration. |
WEATHER_DEFAULT_PUBLIC_SOURCE | open-meteo | No | Factory-default public weather source for new locations without an explicit choice. |
OPEN_METEO_ENABLED | true | No | Instance-wide default for the Open-Meteo source (keyless, EU-focused). Overridable per instance by the platform admin via weather-service management (see Configuring Weather Services) — this variable only sets the starting value. |
DWD_ENABLED | true | No | Instance-wide default for the DWD/Bright Sky source (Deutscher Wetterdienst). Also platform-admin overridable. |
OPENWEATHERMAP_ENABLED | true | No | Instance-wide default for the OpenWeatherMap source. Also platform-admin overridable. |
FROST_FORECAST_HORIZON_DAYS | 2 | No | Forecast horizon in days from today (inclusive) scanned for an expected frost day — the default covers today plus the next day. |
FROST_FORECAST_THRESHOLD_CELSIUS | 2.0 | No | Minimum temperature at or below which a forecast day counts as a frost day. Deliberately separate from the reactive threshold below, set a touch more conservative (closer to 0 °C) since a multi-day-ahead forecast carries more uncertainty than a live reading. |
For comparison — the existing reactive frost threshold (current measured temperature, unchanged by this feature):
| Variable | Default | Required | Description |
|---|---|---|---|
FROST_WARNING_THRESHOLD_CELSIUS | 3.0 | No | Threshold for the reactive frost warning (binary_sensor.kp_{location}_frost_warning), based on the most recently measured air temperature. |
Climate Normals (NASA POWER) ¶
These variables control the monthly background fetch of long-term climate normals (the "Climate at the Site" section) via the keyless NASA POWER reanalysis interface. Both WEATHER_ENABLED and NASA_POWER_CLIMATE_ENABLED must be active for the fetch to run.
| Variable | Default | Required | Description |
|---|---|---|---|
NASA_POWER_CLIMATE_ENABLED | true | No | Dedicated kill switch for the monthly climate-normals task, independent of the general WEATHER_ENABLED — both must be active for the task to run. |
NASA_POWER_BASE_URL | https://power.larc.nasa.gov/api/temporal | No | Base URL of the NASA POWER API. Only relevant for self-hosters with a different network/proxy setup. |
NASA_POWER_CLIMATE_TTL_DAYS | 180 | No | Climate normals barely change; an already-fetched record is only re-fetched after this TTL expires — keeps the monthly task idempotent and spares the NASA POWER API. |
NASA_POWER_DATA_LATENCY_DAYS | 7 | No | Affects the separate daily-values fetch (not the climate normals): number of days NASA POWER needs for quality control of its most recent daily values. |
NASA_POWER_DAILY_DAYS_BACK | 14 | No | Also affects only the daily-values fetch: size of the look-back window in days. |
Only affects outdoor and greenhouse sites with GPS coordinates
Climate normals are materialised only for sites of type Outdoor or Greenhouse with stored GPS coordinates — they're of no use for indoor sites and are not fetched for them. NASA POWER is usable without an API key; the data is licensed under CC BY 4.0 (attribution is delivered automatically alongside the data, see Climate at the Site).
Hardiness Zones (USDA) ¶
This variable controls the quarterly background refresh of a site's hardiness zone, automatically derived from its climate normals (see Climate Zones & Hardiness). The derivation builds on the climate normals — the associated task therefore only runs when both WEATHER_ENABLED and NASA_POWER_CLIMATE_ENABLED are also active.
| Variable | Default | Required | Description |
|---|---|---|---|
HARDINESS_ZONE_REFRESH_ENABLED | true | No | Dedicated kill switch for the quarterly hardiness-zone task (Jan 1 / Apr 1 / Jul 1 / Oct 1, 05:00 UTC), independent of NASA_POWER_CLIMATE_ENABLED — both must be active for the task to run. Manually set zones (hardiness_zone_source: manual) are never overwritten by the task. |
Only affects outdoor and greenhouse sites with GPS coordinates and existing climate normals
Like the climate normals themselves, the hardiness zone is only computed for sites of type Outdoor or Greenhouse with GPS coordinates — and only once at least one climate-normal record with a usable minimum temperature already exists for that site. Triggering it immediately and manually (independent of this schedule) is possible via the API — see API Reference — Hardiness Zones.
Irrigation Demand (ET₀) ¶
These variables control the daily background task that derives the FAO-56 reference evapotranspiration (ET₀) from an outdoor or greenhouse site's weather data and, from it, the net irrigation demand per planting run. The task additionally requires WEATHER_ENABLED=true — without fetched weather data there is nothing to compute. The resulting behaviour for end users is described in Watering Log: Suggested Watering Volume and Care Reminders: Why a Reminder Might Not Appear.
| Variable | Default | Required | Description |
|---|---|---|---|
IRRIGATION_DEMAND_ENABLED | true | No | Dedicated kill switch for the daily compute_irrigation_demand task (06:15), independent of the general WEATHER_ENABLED — both must be active for the task to run. |
IRRIGATION_ROOT_ZONE_DEPTH_MM | 300.0 | No | Assumed effective root-zone depth in millimetres of soil. Used to convert a substrate's water-holding capacity (in percent) into a millimetre cap on the net irrigation demand — prevents an overly high daily recommendation under very dry starting conditions. |
Outdoor and greenhouse sites only, no new REST endpoints
Irrigation demand is calculated only for sites of type Outdoor or Greenhouse with stored GPS coordinates — indoor sites stay on the interval-based watering schedule (REQ-022). There is no dedicated REST endpoint for it; the result flows into the UI through the existing watering-volume suggestion (suggest_volume) and the care-reminder engine.
Calculation basis: aquacropeto (BSD-3-Clause)
The FAO-56 Penman-Monteith and Hargreaves formulas for ET₀ are computed via the Python library aquacropeto (PyPI package aquacropeto, BSD-3-Clause licence) — no ShareAlike/copyleft obligations for the Kamerplanter codebase. See NOTICE.md in the project root for details.
Health endpoint and build identity¶
The unauthenticated endpoint GET /api/health can answer which build is currently running. Because it is unauthenticated, that answer is off by default and the endpoint is rate-limited.
| Variable | Default | Required | Description |
|---|---|---|---|
HEALTH_EXPOSE_BUILD_REVISION | false (the Helm chart sets true) | No | Whether GET /api/health discloses the build_revision field at all. With false the key is absent from the response entirely. The application default stays false — helm/kamerplanter/values.yaml sets true for Kubernetes installations since #1236, so the delivery state is auditable. |
BUILD_REVISION | (empty) | No | The full Git commit the image was built from. It is baked in at container build time (docker-publish.yml passes it into the Dockerfile as a build argument); you only need to set it yourself when you build your own image. |
RATE_LIMIT_HEALTH | 60/minute | No | Rate limit for GET /api/health, per client IP and process — counted in process memory only, so the endpoint never waits on Valkey, not even during an outage. The Kubernetes probes point at /api/v1/health/live and /api/v1/health/ready and are not affected. |
Why the build identity is absent by default
What is sensitive is not the commit hash — the repository is public anyway — but the mapping this host runs that commit. From it follows the exact lag behind the development state, and with it the list of fixes this instance is missing. So enable the field deliberately — for instance on an instance that is only reachable inside your own network, or for the duration of an investigation.
Three distinguishable response states that must not be confused:
| Response | Meaning |
|---|---|
The build_revision key is absent | HEALTH_EXPOSE_BUILD_REVISION is false. Deliberate configuration, not a defect. |
"unknown" | Disclosure is allowed, but no revision was baked in (development image, unstamped build). |
| A 7- to 40-character hexadecimal value | The real answer. An image built by docker-publish.yml reports the full 40-character SHA; a self-built image using BUILD_REVISION=$(git rev-parse --short HEAD) reports correspondingly fewer. |
Before it is reported, the value is checked against ^[0-9a-f]{7,40}$ (after stripping whitespace, so a YAML-folded or shell-quoted value survives). Anything else becomes "unknown" — never a fabricated or derived value.
An operational signal, not an attestation
build_revision states what the instance claims about itself. Whoever compromised the deployment can make it report any hash. The load-bearing proof remains gh attestation verify together with the digest from the pod's .status.containerStatuses[].imageID — see CI/CD — Checks along the delivery chain.
Rate Limiting¶
| Variable | Default | Required | Description |
|---|---|---|---|
RATE_LIMIT_AUTH | 20/minute | No | Rate limit for authentication endpoints |
RATE_LIMIT_STORAGE_URL | empty | No | Storage where the IP rate limits count (redis://…, rediss://…, redis+unix://… or memory://; any other scheme, a password with an unencoded /, # or ?, or an @ in path, query or fragment stops the start — here and in REDIS_URL — and the message does not name the value; an @ in the password itself is accepted). Empty means the Valkey from REDIS_URL, so all replicas and worker processes count together. memory:// counts per process and only fits when one process is the whole installation. If the storage fails with a Redis client error, each process keeps counting on its own (not open, no error, also not for requests waiting at that moment); a fresh window per process can apply, one per window, not one per outage. With a hanging storage only the request that notices the failure (and any request already waiting) waits, one socket timeout of 0.5 s per connection attempt and address (DNS, several addresses, connect plus handshake and the NOSCRIPT reload are not covered), and so does one check request per process at intervals of 1, 2, 4, 8, 16, 32 s and every 32 s after that; each process counts together again only with its own next check request. The mail budgets of the sign-in routes use the same client options and the same check schedule: while Valkey hangs, only the storage call that notices the failure, and one check request per process, waits at most 0.5 s — see IP Rate Limits and Proxy Depth. |
TRUSTED_PROXY_HOPS | 0 | Yes, behind two proxies | How many proxy addresses your infrastructure appends to X-Forwarded-For, counted from the right. 0 = client → nginx → backend (run without ingress, e.g. Docker Compose); 1 = client → ingress controller → nginx → backend (the Helm chart sets this; in the reference installation the ingress is Contour/Envoy). Too low resolves every caller to the nearest proxy — the device-pairing lockout then locks all users at once and IP-allowlisted service accounts fail closed; too high reads entries a caller can forge. At 0 with an X-Forwarded-For chain of more than one entry, the backend logs forwarded_chain_deeper_than_trusted_proxy_hops once per process: either a proxy that is not counted, or a caller sending the header itself (then 0 is correct); raise the value only if there really are more proxies — see Login protection and IP limits. |
RATE_LIMIT_UPLOAD | 30/minute | No | Budget per account and route for file uploads: attachments, plant and task photos, pest images, CSV import (upload and confirmation). Above it 429 with Retry-After. Without a signed-in account the client IP counts. |
RATE_LIMIT_INFERENCE | 20/minute | No | Budget per account and route for routes that run a model: CV diagnosis, pest detection, plant identification, reference contribution, and every route that calls an LLM (generating tips and the daily tip, "Why?", chat message, glossary generation, AI diagnosis, knowledge question POST /api/v1/t/{slug}/ai/knowledge/ask). The daily caps of identification and reference contribution and the daily AI budgets (AI_BUDGET_*) apply on top. |
RATE_LIMIT_EXPORT | 20/minute | No | Budget per account and route for the PDF prints under /print (nutrient plan, care checklist, plant labels). |
RATE_LIMIT_RESEND_VERIFICATION | 10/hour | No | Per-client-IP rate limit for POST /api/v1/auth/resend-verification. It bounds mail to caller-chosen addresses, hence far below RATE_LIMIT_AUTH. The additional per-address limit (3 requests, refilled after an hour without one) is fixed and not configurable. |
RATE_LIMIT_CALENDAR_FEED | 30/minute | No | Per-client-IP rate limit for the public iCal fetch GET /api/v1/calendar/feeds/{key}/feed.ics (REQ-015 CF-004). Calendar apps poll a subscription every 15 minutes to every few hours; beyond the limit 429. Refused fetches with a wrong token count too. |
RATE_LIMIT_CALENDAR_FEED_PER_TOKEN | 60/hour | No | Additional rate limit per presented feed token on the same fetch (REQ-015 CF-004, #2226). Bounds a leaked subscription URL even when it is fetched from many addresses; a subscription on several devices (phone, laptop, Home Assistant) stays far below it. The bucket is keyed by a digest of the token — nobody without the token can spend its budget. |
RATE_LIMIT_INVITATION_EMAIL | 10/minute | No | Per-account rate limit for POST /api/v1/tenants/{slug}/invitations/email: every call sends an email to a freely chosen address. The daily ceiling per account is TENANT_INVITATION_EMAILS_PER_DAY (issue #2162) |
RATE_LIMIT_HEALTH | 60/minute | No | Rate limit for GET /api/health — see Health endpoint and build identity |
Format: [count]/[unit] — units: second, minute, hour, day
Nested Configuration (GBIF)¶
GBIF settings can be passed using the double-underscore delimiter for nesting:
| Variable | Default | Description |
|---|---|---|
GBIF__BASE_URL | https://api.gbif.org/v1 | GBIF API base URL |
GBIF__RATE_LIMIT_PER_MINUTE | 60 | Requests per minute to GBIF |
GBIF__HTTP_TIMEOUT | 30 | Timeout for GBIF requests (seconds) |
Photo Identification (REQ-029)¶
These variables configure optional plant recognition by photo. If none of the API keys are set, the feature is completely disabled — all camera buttons are hidden and no consent dialog is shown.
| Variable | Default | Required | Description |
|---|---|---|---|
PLANTNET_API_KEY | — | No | API key for Pl@ntNet (free tier: ≤ 500 identifications/day). Register at my.plantnet.org. |
PLANTNET_ENABLED | true | No | Disables the Pl@ntNet adapter entirely, even if PLANTNET_API_KEY is set. Set to false to turn off Pl@ntNet despite a configured key (e.g. when relying exclusively on self-hosted DINOv2 recognition). |
PLANTNET_BASE_URL | https://my-api.plantnet.org/v2 | No | Pl@ntNet API base URL. Only change for self-hosting or test endpoints. |
PLANT_ID_API_KEY | — | No | API key for Plant.id (Kindwise) — an additional, purely operator-opt-in cloud adapter (never auto-primary, unlike Pl@ntNet). |
PLANT_ID_BASE_URL | https://plant.id/api/v3 | No | Plant.id API base URL. |
INFERENCE_SERVICE_ENABLED | false | No | Enables the self-hosted DINOv2 recognition path (REQ-029-A). Must be set identically on the backend AND the celery-worker once users can contribute their own reference images — the worker runs the scheduled Art. 17 erasure of those contributions (issue #1753); if the variable is missing there, the worker holds due erasures as a configuration error and a tenant deletion answers with HTTP 503. For the full setup (VectorDB, reference-index population, activation order) see Setting Up Plant Identification. |
INFERENCE_SERVICE_URL | http://kamerplanter-recognition:8000 | No | Internal URL of the inference service. Also set identically on the backend and the celery-worker (see above). |
IDENTIFICATION_PRIMARY_ADAPTER | plantnet | No | Preferred adapter. Possible values: plantnet, local_embedding (DINOv2, once INFERENCE_SERVICE_ENABLED=true). Any other value — for example a typo — is rejected at startup: the API and the worker refuse to start rather than silently using another provider. |
IDENTIFICATION_HTTP_TIMEOUT | 60 | No | HTTP timeout (seconds) for the external identification call (Pl@ntNet's upload + server-side ML inference can exceed the previous 30-second default under load). |
IDENTIFICATION_CONFIDENCE_AUTO_ACCEPT | 0.85 | No | Confidence threshold (0–1) above which a suggestion is highlighted as "very certain". |
IDENTIFICATION_CONFIDENCE_MIN_SHOW | 0.10 | No | Minimum confidence (0–1) required to show a suggestion. Results below this are filtered out. |
IDENTIFICATION_MAX_IMAGE_SIZE_MB | 5 | No | Maximum image size in megabytes. Larger images are rejected with HTTP 400. |
IDENTIFICATION_MAX_IMAGE_DIMENSION | 1024 | No | Longest edge (px) the user image is downscaled to before upload to the adapter. Smaller = faster upload and less third-party bandwidth. |
IDENTIFICATION_RATE_LIMIT_PER_USER_DAY | 50 | No | Maximum requests per user per day (SEC-003 floor, preventing a single account from consuming the whole shared free-tier quota). 0 uses the adapter default limit instead (500 for Pl@ntNet). |
IDENTIFICATION_EXTERNAL_IN_LIGHT_MODE | false | No | Operator opt-in for the external recognition path (Pl@ntNet) in Light Mode. Light Mode has no consent subsystem, so sending a photo to a third party requires a deliberate operator decision. While this stays false, only the self-hosted local_embedding path is usable in Light Mode (once INFERENCE_SERVICE_ENABLED=true is set). |
REFERENCE_CONTRIBUTION_RATE_LIMIT_PER_USER_DAY | 20 | No | Maximum number of reference-image contributions (POST /identification/reference) per user per day — protects the recognition index against abuse/flooding from a single account. 0 disables the limit. Only relevant when self-hosted DINOv2 recognition is active (see Self-Hosted Recognition with DINOv2). |
Pl@ntNet for non-commercial use only
The Pl@ntNet free tier is licensed for non-commercial use. For commercial instances review the terms of use at my.plantnet.org.
Kubernetes Secrets
The PLANTNET_API_KEY should be stored as a Kubernetes Secret:
Feature Toggle Logic¶
PLANTNET_API_KEY set?
├── Yes → Pl@ntNet active (species identification, ≤ 500 IDs/day)
└── No → Feature completely disabled
(camera buttons hidden, no consent dialog)
Pest Detection (REQ-044)¶
These variables configure the optional image-based pest detection feature. The feature is disabled by default — without PEST_DETECTION_ENABLED=true, the "Check for Pests" button is hidden and the app is fully functional.
| Variable | Default | Required | Description |
|---|---|---|---|
PEST_DETECTION_ENABLED | false | No | Master switch. Set to true to enable the feature. Must be set on the backend AND the celery-worker (INFERENCE_SERVICE_ENABLED also satisfies this) once a platform admin has promoted a contributed pest photo into the recognition base — the worker runs the scheduled GDPR Art. 17 erasure of those contributions (issue #1759); if both are missing there, the worker holds due erasures as a configuration error and a tenant deletion answers with HTTP 503. Details: Setting Up Plant Identification. |
PEST_DETECTION_SYMPTOM_ENABLED | true | No | Damage pattern / symptom detection (mode 2) on/off. Active when PEST_DETECTION_ENABLED=true. |
PEST_DETECTION_DETECTOR_ENABLED | false | No | Direct detector (mode 1, Phase 2) on/off. Requires a trained ONNX detector. |
PEST_DETECTION_DEMO_ENABLED | false | No | Demo adapter (no external service, no real model). Previews the full UI flow with clearly-labelled placeholder findings while the trained backend is externally blocked. Preview only — not for real decisions. Active when PEST_DETECTION_ENABLED=true is also set. |
PEST_DETECTION_CLOUD_ENABLED | false | No | Cloud adapter (Kindwise) on/off. Requires PEST_DETECTION_CLOUD_API_KEY. |
PEST_DETECTION_CLOUD_API_KEY | — | No | API key for Kindwise (cloud detection). Without a key the cloud adapter is disabled. |
PEST_DETECTION_PRIMARY_ADAPTER | local_pest_symptom | No | Preferred adapter. Possible values: local_pest_symptom, local_pest_detector (Phase 2), kindwise_pest, demo_pest. Any other value — for example a typo — is rejected at startup: the API and the worker refuse to start rather than silently using another provider. |
PEST_DETECTION_MAX_IMAGE_SIZE_MB | 8 | No | Maximum image size in megabytes. Larger images are rejected with HTTP 400. |
Self-hosted first
The local adapter (local_pest_symptom) requires no API key and no user consent. Cloud detection is opt-in and requires consent (consent purpose pest_detection_cloud).
CV Disease Diagnosis (REQ-038)¶
These variables configure the optional, self-hosted photo diagnosis for diseases and nutrient deficiencies (distinct from Pest Detection above). The feature is disabled by default; without CV_DIAGNOSIS_ENABLED=true the /status API endpoint stays at available: false and the app keeps working without restriction.
| Variable | Default | Required | Description |
|---|---|---|---|
CV_DIAGNOSIS_ENABLED | false | No | Master switch. Set to true to enable the feature. |
CV_CLASSIFIER_CONFIDENCE_SHOW | 0.10 | No | Minimum confidence (0–1) for a hit to be shown. Results below this floor are dropped. |
CV_CLASSIFIER_CONFIDENCE_HIGHLIGHT | 0.75 | No | Confidence threshold (0–1) above which a hit is visually highlighted. Never triggers an automatic creation. |
CV_PHENOTYPE_ENABLED | true | No | PlantCV phenotype metrics (leaf area, green index, discoloration ratio) in the inference service on/off. |
CV_DIAGNOSIS_MAX_IMAGE_SIZE_MB | 5 | No | Maximum image size in megabytes. Larger images are rejected with HTTP 413. |
The classifier runs in the existing inference service and reuses its already-configured connection (INFERENCE_SERVICE_URL, INTERNAL_SERVICE_TOKEN) — no additional connection variables are needed.
Self-hosted, no cloud adapter
Unlike pest detection, CV disease diagnosis (as of this version) has no cloud adapter — photos never leave the instance. Consent plant_diagnosis is still required (Full mode) because a photo is processed (see Privacy & GDPR).
License notices
The model is fine-tuned on the CC-BY-4.0-licensed PlantDoc dataset; the phenotype pipeline uses PlantCV (MPL-2.0). Full attributions: NOTICE.md.
Browser Push / PWA (VAPID)¶
These variables enable the browser push notification channel (channel_key: "pwa"). When all three variables are empty, the channel is disabled — the application remains fully functional and users see "Not configured" in their notification settings.
Step-by-step guide
The Set Up Browser Push guide walks through generating the key pair, storing it in Docker Compose or Kubernetes, and verifying the setup.
| Variable | Default | Required | Description |
|---|---|---|---|
VAPID_PUBLIC_KEY | — | No* | VAPID public key (Base64url, 87 characters). Sent to the browser and used in the PWA subscription. |
VAPID_PRIVATE_KEY | — | No* | VAPID private key (Base64url or PEM). Server-side only — never expose in the frontend or logs. |
VAPID_CONTACT_EMAIL | — | No* | Contact email for the push service (format: mailto:admin@example.com). Used by push services (FCM, APNS, Mozilla) to report issues. |
PWA_PUSH_ENDPOINT_ALLOWED_HOSTS | — (empty) | No | SSRF hardening (SEC-001): comma-separated list of allowed host suffixes for web push endpoints, e.g. fcm.googleapis.com,updates.push.services.mozilla.com. Empty (default) falls back to an HTTPS requirement plus rejection of private IP addresses, so self-hosted push servers keep working. |
*All three VAPID_* variables must be set for the browser push channel to become active. If any variable is missing, the channel remains disabled. PWA_PUSH_ENDPOINT_ALLOWED_HOSTS is independently optional.
Generating a Key Pair¶
Output:
Alternatively with pywebpush (Python) — the b64urlencode step is required because v.public_key/v.private_key are key objects and only serialization yields the Base64url strings:
pip install pywebpush
python3 - <<'PY'
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from cryptography.hazmat.primitives import serialization
v = Vapid()
v.generate_keys()
pub_raw = v.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
priv_raw = v.private_key.private_numbers().private_value.to_bytes(32, "big")
pub, priv = b64urlencode(pub_raw), b64urlencode(priv_raw)
assert pub_raw[0] == 0x04 and len(pub_raw) == 65 and len(pub) == 87, "invalid public key"
assert len(priv_raw) == 32 and len(priv) == 43, "invalid private key"
print("VAPID_PUBLIC_KEY =", pub)
print("VAPID_PRIVATE_KEY=", priv)
PY
Keep the private key server-side
The VAPID_PRIVATE_KEY must never appear in the frontend, in logs, or in public configuration files. Store it as a Kubernetes Secret or Docker Secret — analogous to JWT_SECRET_KEY.
Kubernetes Secret for VAPID
Season & Overwintering Automation¶
These variables control the thresholds of the automatic season/overwintering detection (see Season Automation). They only affect the live and climatological tiers of the detection cascade — the calendar fallback is independent of them.
| Variable | Default | Required | Description |
|---|---|---|---|
SEASON_PRE_WINTER_TEMP_C | 5.0 | No | Temperature threshold (°C) for the transition from "Growing" to "Winter approaching". |
SEASON_FROST_TEMP_C | 2.0 | No | Temperature threshold (°C) for the transition into winter dormancy. |
SEASON_SPRING_TEMP_C | 10.0 | No | Temperature threshold (°C) for the transition into spring reactivation. |
SEASON_SIGNAL_THRESHOLD_DAYS | 3 | No | Number of consecutive signal days required before a transition is triggered (oscillation protection). |
SEASON_STATE_EVAL_ENABLED | true | No | Switch for the daily evaluation task. Set to false to disable season automation entirely. |
Error Tracking (optional)¶
Reports runtime failures to a Sentry-protocol-compatible tracker (reference: GlitchTip). With SENTRY_DSN empty nothing happens — the SDK is never initialised and the frontend does not even download its SDK bundle. With a DSN, the frontend loads it only once the person has consented to error analysis. Full detail: Error tracking.
| Variable | Default | Required | Description |
|---|---|---|---|
SENTRY_DSN | — (empty) | No | The tracker's ingest URL, shaped https://<public-key>@host/<project-id>. Carries only a public key, not a secret. Empty = off. |
SENTRY_ENVIRONMENT | development | No | Stage from the closed vocabulary development, e2e, staging, production. Alert rules filter on these exact values; a deviating value still reports but is logged. |
SENTRY_RELEASE | component + version | No | Image tag or commit SHA. Without it, regression detection and "which deploy introduced this" attribution are impossible. |
SENTRY_SAMPLE_RATE | 1.0 | No | Fraction of events reported (0–1). 1.0 is a deliberate decision for this volume, not an untouched default. Unparseable values fall back to 1.0. |
All four apply to the backend, Celery worker and beat, the inference and knowledge services, and the frontend. In the frontend they are injected at runtime via runtime-config.js rather than baked into the build — one image serves every stage.
Self-hosted tracker: do not forget the NetworkPolicy
On Kubernetes the backend's egress rule excludes the private address ranges. A tracker in your own cluster or LAN therefore needs an additional egress rule — otherwise events are dropped silently.
Complete .env Example¶
# Database
ARANGO_ROOT_PASSWORD=secure-root-password
ARANGODB_HOST=arangodb
ARANGODB_PORT=8529
ARANGODB_DATABASE=kamerplanter
ARANGODB_USERNAME=root
ARANGODB_PASSWORD=secure-root-password
# Cache / Queue
REDIS_URL=redis://valkey:6379/0
# Security (all four are mandatory secrets, startup gate in production)
JWT_SECRET_KEY=generate-with-openssl-rand-hex-32
FERNET_KEY=generate-with-Fernet.generate_key
ERASURE_TOMBSTONE_SALT=generate-with-openssl-rand-hex-32
LOG_PSEUDONYM_SALT=generate-with-openssl-rand-hex-32
# Default true; set false only without outbound mail
REQUIRE_EMAIL_VERIFICATION=true
# Who may create an account: open | invite_only | closed (default open)
REGISTRATION_MODE=open
# CORS
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Operating mode
KAMERPLANTER_MODE=full
DEBUG=false
# Email (development)
EMAIL_ADAPTER=console
# mDNS Discovery (LAN only, opt-in)
# MDNS_ENABLED=false
# INSTANCE_ID=
# Optional external APIs
PERENUAL_API_KEY=
HA_URL=
HA_ACCESS_TOKEN=
# Knowledge Service — Re-Ranking (empty = disabled)
RERANKER_URL=
RERANKER_INITIAL_K=15
RERANKER_MAX_DOCUMENT_CHARS=500
RERANKER_TOP_K=5
# AI Assistant (disabled instance-wide unless explicitly enabled)
AI_FEATURES_ENABLED=false
KNOWLEDGE_SERVICE_ENABLED=false
KNOWLEDGE_SERVICE_URL=http://knowledge-service:8000
# Photo identification (empty = feature disabled)
# PLANTNET_API_KEY=
# IDENTIFICATION_RATE_LIMIT_PER_USER_DAY=50
# Browser Push / PWA (empty = channel disabled)
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_CONTACT_EMAIL=mailto:admin@example.com
Object Storage (NFR-013)¶
These variables configure the storage adapter for binary data (photos, imports, exports). The active backend is determined by STORAGE_BACKEND. By default, local-fs is active — no additional configuration required.
For background information, see Configure Storage (Object Storage) and Helm Charts — Storage Configuration.
General Storage Settings¶
| Variable | Default | Required | Description |
|---|---|---|---|
STORAGE_BACKEND | local-fs | No | Active backend: local-fs or s3. Any other value is rejected at startup. |
STORAGE_MAX_FILE_SIZE_MB | 25 | No | Maximum upload size in megabytes (applies to all categories, overridable per category) |
STORAGE_PRESIGN_TTL_SECONDS | 900 | No | Validity period of pre-signed URLs in seconds (max. 3600) |
UPLOAD_SPOOL_BUDGET_MB | 52 | No | How many MiB concurrent uploads may hold in /tmp together. It counts the bytes that actually arrived. Must be at least STORAGE_MAX_FILE_SIZE_MB + 1, otherwise the backend does not start. Keep it below the size of /tmp in the backend pod (64 MiB in the chart). The Helm chart sets it to 52 or, with a larger upload limit, to storage.maxFileSizeMb + 1. It also caps the tenant import's upload limit (TENANT_IMPORT_MAX_UPLOAD_MB): to import archives up to N MiB, raise it to at least N + 1 together with /tmp and the memory limit (issue #2138). |
UPLOAD_SPOOL_WAIT_SECONDS | 30 | No | How long an upload waits for free budget. After that the backend answers 503 (UPLOAD_CAPACITY_EXHAUSTED) with Retry-After. |
STORAGE_ALLOWED_MIME_TYPES | (list) | No | Comma-separated global whitelist of allowed MIME types |
STORAGE_ALLOWED_MIME_TYPES_<CATEGORY> | (per category) | No | Category-specific whitelist, e.g. STORAGE_ALLOWED_MIME_TYPES_IMPORT=text/csv |
STORAGE_VIRUS_SCAN_ENABLED | false | No | Enable virus scanning via ClamAV REST wrapper |
STORAGE_VIRUS_SCAN_ENDPOINT | (empty) | No | URL of the ClamAV REST wrapper |
STORAGE_STRIP_EXIF | true | No | Strips EXIF/GPS metadata from image uploads globally at save time (NFR-013 §5.1). There is no per-category override variable — unlike the MIME whitelists, this is a single global switch. While it is on, the upload refuses image types whose metadata the strip cannot remove (HEIC/HEIF, GIF), even when a MIME whitelist admits them. Images above 40 megapixels are refused with 413 regardless of this switch. |
STORAGE_TENANT_QUOTA_MB | 2048 | No | Storage quota per tenant, in megabytes. 0 disables the quota (unlimited). |
STORAGE_MAX_PHOTOS_PER_INSTANCE | 50 | No | Maximum number of gallery photos per plant instance (REQ-034). 0 disables the limit. |
STORAGE_TASK_PHOTO_ORPHAN_HOURS | 0 | No | How long a task photo may sit unreferenced before the nightly sweep deletes it (#1393). 0 disables it, and is the shipped default: the job deletes data over a reference history spanning every client version and a manual migration, and does not go live in its first release. To switch it on, set a positive number of hours; every upload is briefly unreferenced (the attachment row is written before the form links it), so the value is a wide safety margin rather than a tuning knob — 48 is the figure this change was built and tested around. |
STORAGE_RECONCILE_DELETE_ENABLED | false | No | Switches the nightly object reconciliation from reporting to deleting (#1834). The task reconcile_orphaned_storage_objects (daily 04:10) lists the object store under t/ and finds objects no attachment record holds any more: leftovers of a storage call that failed after a deletion, of a record created inside an account erasure's window, or of a pest reference in a tenant the user left. Shipped as false: the run only reports. Every run writes a reconcile_orphaned_storage_objects_audit line to the worker log with scanned, found (no record holds the object), young (younger than the safety margin, kept), undated (no readable age, kept), eligible, would_delete, deleted, failed, freed_bytes and truncated; it never contains a tenant key, user key or object path. Set true only once would_delete describes, over several runs, nothing but objects you expect; the run then deletes exactly those objects with their renditions and never one a record holds. |
STORAGE_RECONCILE_MIN_AGE_HOURS | 24 | No | Minimum age of an object without a record before the reconciliation judges it (#1834). An upload writes the object before its record, so a fresh unheld object is briefly normal. The floor is 1 (one request plus the thumbnail task's retries); smaller values are refused at startup. A safety margin, not a tuning knob. |
STORAGE_RECONCILE_MAX_OBJECTS_PER_RUN | 200000 | No | Most objects one reconciliation run examines (floor 1000). A larger store is covered over several nights: the report shows truncated: true and the next run resumes at the remembered position (token kept in Valkey; without it the run starts over). |
STORAGE_RECONCILE_MAX_ORPHAN_FRACTION | 0.5 | No | Brake of the object reconciliation (#1834): when more than this share of a page's objects (from 20 objects up) have no record, the run deletes nothing there and reports brake_tripped: true. It catches a catalogue that answers validly but empty — a wrong database over an existing bucket, a restore older than the store, two installations on one bucket — where no query fails and every object would look orphaned. Range 0 < x <= 1. When it trips, check the catalogue before leaving deletion on. |
Default MIME whitelist per category:
| Category | Allowed MIME types | Max size |
|---|---|---|
diary, ipm, harvest, post_harvest, task, id_recognition, plant | image/jpeg, image/png, image/webp | 25 MB |
import | text/csv, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | 50 MB |
export | application/pdf, text/csv, application/zip | 200 MB |
tenant_export | application/zip | 5 GB |
Backend: Local Filesystem (local-fs)¶
| Variable | Default | Required | Description |
|---|---|---|---|
STORAGE_LOCAL_FS_ROOT | /data/attachments | No | Mount path inside the container |
STORAGE_LOCAL_FS_PUBLIC_BASE_URL | (empty) | Yes* | Full URL of the token download endpoint, e.g. https://api.kamerplanter.example.com/api/v1/attachments/token. Must point to https://<host>/api/v1/attachments/token. |
STORAGE_LOCALFS_SIGNING_SECRET | (ephemeral) | Yes** | Secret key for token signatures. Required when running more than one replica, otherwise tokens cannot be validated by other pods. |
*Required for local-fs token downloads to work. **Required in multi-replica operation.
Store signing secret as a Kubernetes Secret
STORAGE_LOCALFS_SIGNING_SECRET is a cryptographic secret and must not be committed to values.yaml or Git in plain text. Create it as a Kubernetes Secret:
Backend: S3-compatible (s3)¶
| Variable | Default | Required | Description |
|---|---|---|---|
STORAGE_S3_ENDPOINT_URL | (empty) | Yes | Full endpoint URL, e.g. https://s3.eu-central-1.amazonaws.com |
STORAGE_S3_REGION | (empty) | Yes | Region, e.g. eu-central-1 (also required for MinIO) |
STORAGE_S3_BUCKET | (empty) | Yes | Bucket name (must be created beforehand) |
STORAGE_S3_ACCESS_KEY_ID | (empty) | Yes | Access key (from External Secrets Operator — never in plain text in Git) |
STORAGE_S3_SECRET_ACCESS_KEY | (empty) | Yes | Secret access key (from External Secrets Operator — never in plain text in Git) |
STORAGE_S3_USE_PATH_STYLE | false | No | true for MinIO and most non-AWS providers |
STORAGE_S3_FORCE_TLS | true | No | Block plain HTTP; set to false in dev environments |
STORAGE_S3_KMS_KEY_ID | (empty) | No | Optional customer-managed key for server-side encryption (SSE-KMS) |
STORAGE_S3_ALLOW_PRIVATE_ENDPOINT | false | No | true for in-cluster MinIO that is not publicly reachable |
Never put S3 credentials in Git or values.yaml
STORAGE_S3_ACCESS_KEY_ID and STORAGE_S3_SECRET_ACCESS_KEY are secrets and must be provided exclusively through the External Secrets Operator (ESO) or Kubernetes Secrets. For details, see Helm Charts — Storage Configuration.
Example Configurations¶
STORAGE_BACKEND=s3
STORAGE_S3_ENDPOINT_URL=http://minio.kamerplanter.svc:9000
STORAGE_S3_REGION=us-east-1
STORAGE_S3_BUCKET=kamerplanter
STORAGE_S3_ACCESS_KEY_ID=<from secret>
STORAGE_S3_SECRET_ACCESS_KEY=<from secret>
STORAGE_S3_USE_PATH_STYLE=true
STORAGE_S3_FORCE_TLS=false
STORAGE_S3_ALLOW_PRIVATE_ENDPOINT=true
Frequently Asked Questions¶
Can I store environment variables as Kubernetes Secrets?
Yes. Use Kubernetes Secrets for sensitive values (ARANGODB_PASSWORD, JWT_SECRET_KEY) and reference them in the Deployment manifest via valueFrom.secretKeyRef.
How can I verify which values the backend is actually using?
With DEBUG=true, the backend logs all loaded settings at startup. Alternatively, inside the container:
See Also¶
- Configuration Matrix — Feature → services → switches → mandatory secrets → resources in a single table
- Deployment Profiles — Recommended component bundles for typical use cases
- Local Setup
- Operations Troubleshooting
- Kubernetes Deployment
- Weather Sources per Location — User Guide
- Notifications: Frost Early-Warning — User Guide
- API Reference: CV Disease Diagnosis
- MCP Server
- Privacy & GDPR — AI Disease Diagnosis
- Watering Log: Suggested Watering Volume — User Guide
- Equipment & Inventory (InvenTree) — User Guide
- Environment Control & Actuators — User Guide
- API Reference: Environment Control & Actuators