Server Configuration ==================== All configuration is environment-driven, optionally backed by a ``.env`` file (via ``django-environ``). A subset of settings — the public base URL, database URL, and encryption key — may also be set through the first-run onboarding wizard and persisted to ``heartlock_data/appconfig.json``; for those, **the config file takes precedence** over environment variables. Core settings -------------- .. list-table:: :widths: 30 10 20 40 :header-rows: 1 * - Variable - Required in prod - Default - Notes * - ``SECRET_KEY`` - yes - ``django-insecure-dev-key-change-me-in-production`` - Django secret key * - ``DEBUG`` - no - ``True`` - Set ``False`` in production * - ``ALLOWED_HOSTS`` - no - ``localhost,127.0.0.1,0.0.0.0`` - Comma-separated list of host/domain names * - ``DATABASE_URL`` - no - ``sqlite:///heartlock_data/db.sqlite3`` - ``postgres://user:pass@host:5432/db`` in compose. Overridden by the onboarding config file. * - ``HEARTLOCK_ENCRYPTION_KEY`` - **yes** - *(generated in DEBUG)* - Base64 32-byte Fernet key * - ``HEARTLOCK_APP_URL`` - no - *(empty)* - Public base URL (no trailing slash). Falls back to the request host if unset. * - ``HEARTLOCK_DISABLE_ONBOARDING`` - no - ``False`` - Disable the first-run onboarding wizard entirely. * - ``HEARTLOCK_INVITE_EXPIRY_DAYS`` - no - ``7`` - Days before a project invitation link expires. .. _encryption-key: Encryption at rest ------------------ Every secret value stored in Heartlock is encrypted with Fernet symmetric encryption using ``HEARTLOCK_ENCRYPTION_KEY``. **You must set this explicitly in production.** To generate a key: .. code-block:: bash python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" .. warning:: If you lose the encryption key, **all stored secret values become irrecoverable.** Store it securely (a secrets manager, not a plaintext file in version control). Rotating the key requires re-encrypting existing secrets — changing the key value without re-encrypting will make all stored secret values permanently unreadable. In development (``DEBUG=True``), if the key is unset, one is generated and persisted to ``heartlock_data/secret.key``. Precedence for the encryption key: 1. Onboarding config file (``heartlock_data/appconfig.json``) 2. ``HEARTLOCK_ENCRYPTION_KEY`` environment variable 3. ``heartlock_data/secret.key`` file (dev only) 4. Ephemeral in-memory key (not-yet-onboarded production) Database -------- Heartlock supports **SQLite** (for local development / small deployments) and **PostgreSQL** (recommended for production). SQLite (default) ^^^^^^^^^^^^^^^^^ When ``DATABASE_URL`` is unset and no onboarding config exists, Heartlock uses ``sqlite:///heartlock_data/db.sqlite3`` (the file is created automatically under the project root). PostgreSQL ^^^^^^^^^^ .. code-block:: bash DATABASE_URL=postgres://heartlock:password@db:5432/heartlock The Docker Compose stack runs PostgreSQL 17 by default. The ``psycopg`` package (binary) is included in ``requirements.txt``. The ``DB_CONN_MAX_AGE`` setting (default ``60``) controls persistent-connection lifetime in seconds. Set to ``0`` for a fresh connection per request. CORS and CSRF -------------- .. list-table:: :widths: 30 20 50 :header-rows: 1 * - Variable - Required in prod - Notes * - ``CORS_ALLOWED_ORIGINS`` - no - Comma-separated origins, e.g. ``https://app.example.com`` * - ``CSRF_TRUSTED_ORIGINS`` - no - Comma-separated origins for unsafe cross-origin requests. Falls back to ``CORS_ALLOWED_ORIGINS`` if unset. Example: .. code-block:: ini CORS_ALLOWED_ORIGINS=https://heartlock.example.com CSRF_TRUSTED_ORIGINS=https://heartlock.example.com OIDC single sign-on -------------------- Heartlock acts as an OIDC **client** (relying party) to an external identity provider — Keycloak, Authentik, Auth0, Dex, Google, etc. .. list-table:: :widths: 35 10 55 :header-rows: 1 * - Variable - Required - Notes * - ``OIDC_ENABLE`` - no - Enable OIDC SSO. When all endpoint settings are present and this is not explicitly ``False``, SSO activates automatically. * - ``OIDC_CLIENT_ID`` - no - OIDC client ID (canonical; legacy alias: ``OIDC_RP_CLIENT_ID``) * - ``OIDC_CLIENT_SECRET`` - no - OIDC client secret (canonical; legacy alias: ``OIDC_RP_CLIENT_SECRET``) * - ``OIDC_AUTHORIZATION_ENDPOINT`` - no - e.g. ``https://idp.example.com/auth`` (alias: ``OIDC_OP_AUTHORIZATION_ENDPOINT``) * - ``OIDC_TOKEN_ENDPOINT`` - no - e.g. ``https://idp.example.com/token`` (alias: ``OIDC_OP_TOKEN_ENDPOINT``) * - ``OIDC_USERINFO_ENDPOINT`` - no - e.g. ``https://idp.example.com/me`` (alias: ``OIDC_OP_USERINFO_ENDPOINT``) * - ``OIDC_SCOPES`` - no - Default: ``openid email profile`` (alias: ``OIDC_RP_SCOPES``) * - ``OIDC_END_SESSION_ENDPOINT`` - no - Optional; redirect to the IdP end-session endpoint on logout. * - ``OIDC_REDIRECT_URI`` - no - Optional; absolute callback URL. Defaults to ``/oidc/callback/``. * - ``OIDC_CREATE_UNKNOWN_USER`` - no - Default ``True``; auto-provision a local user when the IdP returns an unknown email. Example configuration for Keycloak: .. code-block:: ini OIDC_ENABLE=true OIDC_CLIENT_ID=heartlock OIDC_CLIENT_SECRET=your-client-secret OIDC_AUTHORIZATION_ENDPOINT=https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth OIDC_TOKEN_ENDPOINT=https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token OIDC_USERINFO_ENDPOINT=https://keycloak.example.com/realms/myrealm/protocol/openid-connect/userinfo OIDC_END_SESSION_ENDPOINT=https://keycloak.example.com/realms/myrealm/protocol/openid-connect/logout OIDC admin API ^^^^^^^^^^^^^^ Superusers can also configure OIDC through the admin API at ``/api/v1/admin/oidc/``, which writes to the persisted config file. See :doc:`api-reference` for endpoint details. Config-file precedence ^^^^^^^^^^^^^^^^^^^^^^ OIDC settings follow the same precedence as other config-file-managed values: 1. ``heartlock_data/appconfig.json`` (set via the admin API) 2. Environment variables (canonical names, then legacy ``OIDC_OP_`` / ``OIDC_RP_`` aliases) 3. Built-in defaults Email / SMTP ------------- In development, leave ``EMAIL_HOST`` empty and Django prints emails to the console. For production, point these at your SMTP relay. .. list-table:: :widths: 30 15 15 40 :header-rows: 1 * - Variable - Required in prod - Default - Notes * - ``EMAIL_HOST`` - no - *(empty)* - When empty, emails print to the console * - ``EMAIL_PORT`` - no - ``587`` - * - ``EMAIL_HOST_USER`` - no - *(empty)* - * - ``EMAIL_HOST_PASSWORD`` - no - *(empty)* - * - ``EMAIL_USE_TLS`` - no - ``True`` - * - ``EMAIL_USE_SSL`` - no - ``False`` - * - ``EMAIL_TIMEOUT`` - no - ``10`` - * - ``DEFAULT_FROM_EMAIL`` - no - ``Heartlock `` - Setting ``EMAIL_HOST`` automatically switches the backend from console to SMTP. Override ``EMAIL_BACKEND`` explicitly if needed. Production hardening --------------------- When ``DEBUG=False``: - ``SECURE_SSL_REDIRECT`` defaults to ``True`` — terminate TLS at a reverse proxy and let it send ``X-Forwarded-Proto``, or set ``SECURE_SSL_REDIRECT=False`` explicitly. - ``SESSION_COOKIE_SECURE`` is forced to ``True``. - ``CSRF_COOKIE_SECURE`` is forced to ``True``. - HSTS headers are enabled (``SECURE_HSTS_SECONDS = 2592000``, ``SECURE_HSTS_INCLUDE_SUBDOMAINS = True``, ``SECURE_HSTS_PRELOAD = True``). - ``SECURE_CONTENT_TYPE_NOSNIFF`` is forced to ``True``. Docker image details -------------------- The multi-stage ``Dockerfile``: 1. **``ui-build`` stage** (``node:22-bookworm-slim``) — installs Angular dependencies, runs the production build, and rewrites asset paths to ``/static/ui/...``. 2. **``final`` stage** (``python:3.14-slim``) — installs Python dependencies, copies the backend source and built UI, creates a non-root ``heartlock`` user, and runs ``collectstatic && migrate && hypercorn``. The server binds ``0.0.0.0:8082``. ``STATIC_URL`` defaults to ``static/`` and ``STATIC_ROOT`` to ``./staticfiles``. Security notes -------------- - **Never commit** ``.env``, ``db.sqlite3``, or ``heartlock_data/``. - Service tokens (CLI tokens) are stored **hashed**, not in plaintext. - Secret values are **encrypted at rest** with Fernet; see :ref:`encryption-key`. - The onboarding wizard is only available while **no administrator account** exists. Once a superuser is created, it is permanently closed.