Skip to content

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:

cp .env.example .env


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:

openssl rand -hex 32
Changing 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:

CORS_ORIGINS='["https://app.example.com","https://app2.example.com"]'


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:

  1. 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.
  2. 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:

apiVersion: v1
kind: Secret
metadata:
  name: kamerplanter-identification
type: Opaque
stringData:
  PLANTNET_API_KEY: "your-api-key"

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

npx web-push generate-vapid-keys

Output:

Public Key:
BNm...

Private Key:
8Kv...

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

apiVersion: v1
kind: Secret
metadata:
  name: kamerplanter-vapid
type: Opaque
stringData:
  VAPID_PUBLIC_KEY: "BNm..."
  VAPID_PRIVATE_KEY: "8Kv..."
  VAPID_CONTACT_EMAIL: "mailto:admin@example.com"

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:

kubectl create secret generic kamerplanter-storage-signing \
  --from-literal=STORAGE_LOCALFS_SIGNING_SECRET="$(openssl rand -hex 32)" \
  --namespace kamerplanter

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=https://s3.eu-central-1.amazonaws.com
STORAGE_S3_REGION=eu-central-1
STORAGE_S3_BUCKET=my-kamerplanter-bucket
STORAGE_S3_ACCESS_KEY_ID=<from secret>
STORAGE_S3_SECRET_ACCESS_KEY=<from secret>
STORAGE_S3_USE_PATH_STYLE=false
STORAGE_S3_FORCE_TLS=true
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
STORAGE_BACKEND=s3
STORAGE_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com
STORAGE_S3_REGION=eu-central
STORAGE_S3_BUCKET=my-kamerplanter-bucket
STORAGE_S3_ACCESS_KEY_ID=<from secret>
STORAGE_S3_SECRET_ACCESS_KEY=<from secret>
STORAGE_S3_USE_PATH_STYLE=false
STORAGE_S3_FORCE_TLS=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:

docker compose exec backend python -c "from app.config.settings import settings; print(settings.model_dump())"
Passwords and secrets are not shown in plain text.


See Also