fix(api): stop shipping working secrets in self-hosted env

This commit is contained in:
pedrooot committed 2026-10-06 14:59:55 +02:00
1 parent d38e04dd3e
commit 28aed610d2
11 files changed
+353 -9

No files matched your search

+6 -4
View File
@@ -14,8 +14,8 @@ NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
AUTH_TRUST_HOST=true
UI_PORT=3000
# openssl rand -base64 32
AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
# Required before the first start, no default is shipped: openssl rand -base64 32
AUTH_SECRET=""
# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress)
# deprecated, use UI_GOOGLE_TAG_MANAGER_ID
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=""
@@ -155,8 +155,10 @@ DJANGO_MANAGE_DB_PARTITIONS=True
DJANGO_TOKEN_SIGNING_KEY=""
# openssl rsa -in private.pem -pubout -out public.pem
DJANGO_TOKEN_VERIFYING_KEY=""
# openssl rand -base64 32
DJANGO_SECRETS_ENCRYPTION_KEY="oE/ltOhp/n1TdbHjVmzcjDPLcLA41CVI/4Rk+UB5ESc="
# Fernet key protecting every stored credential. Leave empty to generate it on the
# first start, persisted in _data/api/secrets_encryption.key: keep that file across
# upgrades. To set it yourself: openssl rand -base64 32
DJANGO_SECRETS_ENCRYPTION_KEY=""
DJANGO_BROKER_VISIBILITY_TIMEOUT=86400
DJANGO_SENTRY_DSN=
DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
+5
View File
@@ -185,6 +185,8 @@ VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/la
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/.env"
# AUTH_SECRET is not shipped: set it in .env before the first start
perl -pi -e "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env
docker compose up -d
```
@@ -195,6 +197,9 @@ $VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/p
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
# AUTH_SECRET is not shipped: set it in .env before the first start
$AuthSecret = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
(Get-Content .env) -replace '^AUTH_SECRET=.*', "AUTH_SECRET=`"$AuthSecret`"" | Set-Content .env
docker compose up -d
```
@@ -0,0 +1 @@
PostgreSQL, Valkey and Neo4j ports in the self-hosted `docker-compose.yml` bind to `127.0.0.1` instead of every host interface, and `worker` and `worker-beat` share the API key directory
@@ -0,0 +1 @@
`DJANGO_SECRETS_ENCRYPTION_KEY` and `SECRET_KEY` are generated on the first start and persisted under `_data/api` when empty, the API refuses to start with an encryption key that was ever published in this repository, and the self-hosted `.env` no longer ships working values for `DJANGO_SECRETS_ENCRYPTION_KEY` and `AUTH_SECRET`
+141
View File
@@ -1,32 +1,65 @@
import hashlib
import logging
import os
import sys
import time
from pathlib import Path
from config.custom_logging import BackendLogger
from config.env import env
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
logger = logging.getLogger(BackendLogger.API)
SIGNING_KEY_ENV = "DJANGO_TOKEN_SIGNING_KEY"
VERIFYING_KEY_ENV = "DJANGO_TOKEN_VERIFYING_KEY"
ENCRYPTION_KEY_ENV = "DJANGO_SECRETS_ENCRYPTION_KEY"
SECRET_KEY_ENV = "SECRET_KEY"
PRIVATE_KEY_FILE = "jwt_private.pem"
PUBLIC_KEY_FILE = "jwt_public.pem"
ENCRYPTION_KEY_FILE = "secrets_encryption.key"
SECRET_KEY_FILE = "django_secret.key"
KEYS_DIRECTORY = (
Path.home() / ".config" / "prowler-api"
) # `/home/prowler/.config/prowler-api` inside the container
# SHA-256 of encryption keys that were committed to this repository with a working
# value. They are public, so a deployment still using one of them must rotate.
PUBLISHED_ENCRYPTION_KEY_DIGESTS = frozenset(
{
# Root `.env` shipped to self-hosted installs until this check was added
"a9c1d15afaddc1753245e48117f7c80525973a4f64179d447a843ca372c0ba0e",
# contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml
"c7576d54c7120bab3a486d44a7a8640907cc28eaeec6bc41f4aa2e95ad407015",
}
)
ENCRYPTION_KEY_DOCS = (
"https://docs.prowler.com/getting-started/installation/prowler-app"
"#secrets-encryption-key"
)
_keys_initialized = False # Flag to prevent multiple executions within the same process
def _digest(value):
return hashlib.sha256(value.encode()).hexdigest()
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api"
def import_models(self):
# `api.models` builds its Fernet instance at import time, so the encryption
# key has to be settled before Django loads this app's models.
self._ensure_secrets()
super().import_models()
def ready(self):
from api import (
schema_extensions, # noqa: F401
@@ -42,11 +75,119 @@ class ApiConfig(AppConfig):
):
self._ensure_crypto_keys()
def _ensure_secrets(self):
"""
Settle the symmetric secrets every process needs before the models load:
- `DJANGO_SECRETS_ENCRYPTION_KEY`, the Fernet key protecting stored credentials
- `SECRET_KEY`, Django's signing key
Both are generated and persisted under `KEYS_DIRECTORY` when empty. An
encryption key that was ever published with this repository is refused.
"""
if getattr(settings, "TESTING", False):
return
self._ensure_encryption_key()
self._ensure_secret_key()
def _ensure_encryption_key(self):
key = (getattr(settings, "SECRETS_ENCRYPTION_KEY", "") or "").strip()
if key:
if _digest(key) in PUBLISHED_ENCRYPTION_KEY_DIGESTS:
raise ImproperlyConfigured(
f"'{ENCRYPTION_KEY_ENV}' is set to a value that was published in the "
"Prowler repository and is therefore public. Generate a new key, "
f"re-enter the stored credentials and restart. See {ENCRYPTION_KEY_DOCS}"
)
return
key = self._load_or_generate_secret(
ENCRYPTION_KEY_FILE, ENCRYPTION_KEY_ENV, self._generate_encryption_key
)
os.environ[ENCRYPTION_KEY_ENV] = key
settings.SECRETS_ENCRYPTION_KEY = key
# Mutated in place: `drf_simple_apikey` keeps a reference to this dict
settings.DRF_API_KEY["FERNET_SECRET"] = key
def _ensure_secret_key(self):
# Read from the environment: `settings.SECRET_KEY` raises while it is empty
if env.str(SECRET_KEY_ENV, default="").strip():
return
key = self._load_or_generate_secret(
SECRET_KEY_FILE, SECRET_KEY_ENV, self._generate_secret_key
)
os.environ[SECRET_KEY_ENV] = key
settings.SECRET_KEY = key
def _load_or_generate_secret(self, file_name, env_name, generate):
"""
Return the secret persisted as `file_name`, generating it on first boot.
"""
existing = self._read_key_file(file_name)
if existing:
return existing
file_path = KEYS_DIRECTORY / file_name
value = generate()
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
# Another process of this deployment generated it first
return self._wait_for_key_file(file_name, env_name)
except OSError as e:
logger.error(f"Cannot persist '{file_name}' under '{KEYS_DIRECTORY}': {e}")
raise ImproperlyConfigured(
f"'{env_name}' is empty and no value could be persisted under "
f"'{KEYS_DIRECTORY}'. Set '{env_name}' in the environment or make the "
"directory writable."
) from e
with os.fdopen(fd, "w") as key_file:
key_file.write(value)
logger.warning(
f"'{env_name}' was empty: generated '{file_name}' under '{KEYS_DIRECTORY}'. "
"Keep that file across upgrades and back it up; losing it makes the data "
"it protects unreadable."
)
return value
def _wait_for_key_file(self, file_name, env_name):
for _ in range(10):
existing = self._read_key_file(file_name)
if existing:
return existing
time.sleep(0.1)
raise ImproperlyConfigured(
f"'{env_name}' is empty and '{file_name}' under '{KEYS_DIRECTORY}' could "
"not be read. Set the variable in the environment or remove the file."
)
@staticmethod
def _generate_encryption_key():
from cryptography.fernet import Fernet
return Fernet.generate_key().decode()
@staticmethod
def _generate_secret_key():
from django.core.management.utils import get_random_secret_key
return get_random_secret_key()
def _ensure_crypto_keys(self):
"""
Orchestrator method that ensures all required cryptographic keys are present.
This method coordinates the generation of:
- RSA key pairs for JWT token signing and verification
The symmetric secrets are handled earlier, in `_ensure_secrets`, because the
models need them at import time.
Note: During development, Django spawns multiple processes (migrations, fixtures, etc.)
which will each generate their own keys. This is expected behavior and each process
will have consistent keys for its lifetime. In production, set the keys as environment
+167
View File
@@ -8,13 +8,20 @@ import api
import api.apps as api_apps_module
import pytest
from api.apps import (
ENCRYPTION_KEY_ENV,
ENCRYPTION_KEY_FILE,
PRIVATE_KEY_FILE,
PUBLIC_KEY_FILE,
SECRET_KEY_ENV,
SECRET_KEY_FILE,
SIGNING_KEY_ENV,
VERIFYING_KEY_ENV,
ApiConfig,
)
from cryptography.fernet import Fernet
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@pytest.fixture(autouse=True)
@@ -203,3 +210,163 @@ def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
config.ready()
init_driver.assert_not_called()
@pytest.fixture
def secrets_dir(monkeypatch, tmp_path):
"""Isolated key directory with both symmetric secrets empty and generation enabled."""
monkeypatch.setattr(
api_apps_module, "KEYS_DIRECTORY", Path(tmp_path), raising=False
)
monkeypatch.setattr(settings, "TESTING", False, raising=False)
monkeypatch.setattr(settings, "SECRET_KEY", settings.SECRET_KEY, raising=False)
monkeypatch.setattr(settings, "SECRETS_ENCRYPTION_KEY", "", raising=False)
monkeypatch.setattr(
settings, "DRF_API_KEY", settings.DRF_API_KEY.copy(), raising=False
)
monkeypatch.delenv(ENCRYPTION_KEY_ENV, raising=False)
monkeypatch.delenv(SECRET_KEY_ENV, raising=False)
return Path(tmp_path)
def _throwaway_fernet_key():
return Fernet.generate_key().decode()
def test_ensure_secrets_refuses_published_encryption_key(monkeypatch, secrets_dir):
published = _throwaway_fernet_key()
monkeypatch.setattr(settings, "SECRETS_ENCRYPTION_KEY", published, raising=False)
monkeypatch.setattr(
api_apps_module,
"PUBLISHED_ENCRYPTION_KEY_DIGESTS",
frozenset({api_apps_module._digest(published)}),
raising=False,
)
with pytest.raises(ImproperlyConfigured) as exc_info:
_make_app()._ensure_secrets()
assert ENCRYPTION_KEY_ENV in str(exc_info.value)
assert published not in str(exc_info.value)
assert not (secrets_dir / ENCRYPTION_KEY_FILE).exists()
def test_ensure_secrets_generates_encryption_key_when_empty(secrets_dir):
_make_app()._ensure_secrets()
key_path = secrets_dir / ENCRYPTION_KEY_FILE
generated = key_path.read_text()
assert key_path.stat().st_mode & 0o777 == 0o600
Fernet(generated.encode())
assert settings.SECRETS_ENCRYPTION_KEY == generated
assert settings.DRF_API_KEY["FERNET_SECRET"] == generated
assert os.environ[ENCRYPTION_KEY_ENV] == generated
def test_ensure_secrets_reuses_persisted_encryption_key(monkeypatch, secrets_dir):
persisted = _throwaway_fernet_key()
(secrets_dir / ENCRYPTION_KEY_FILE).write_text(persisted + "\n")
def _fail_generate():
raise AssertionError("a persisted encryption key must not be regenerated")
monkeypatch.setattr(
ApiConfig, "_generate_encryption_key", staticmethod(_fail_generate)
)
_make_app()._ensure_secrets()
assert settings.SECRETS_ENCRYPTION_KEY == persisted
assert (secrets_dir / ENCRYPTION_KEY_FILE).read_text() == persisted + "\n"
def test_ensure_secrets_keeps_explicit_encryption_key(monkeypatch, secrets_dir):
explicit = _throwaway_fernet_key()
monkeypatch.setattr(settings, "SECRETS_ENCRYPTION_KEY", explicit, raising=False)
_make_app()._ensure_secrets()
assert settings.SECRETS_ENCRYPTION_KEY == explicit
assert not (secrets_dir / ENCRYPTION_KEY_FILE).exists()
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
def test_ensure_secrets_refuses_empty_encryption_key_when_unpersistable(
monkeypatch, secrets_dir
):
secrets_dir.chmod(0o500)
monkeypatch.setattr(
api_apps_module, "KEYS_DIRECTORY", secrets_dir / "keys", raising=False
)
try:
with pytest.raises(ImproperlyConfigured) as exc_info:
_make_app()._ensure_secrets()
finally:
secrets_dir.chmod(0o700)
assert ENCRYPTION_KEY_ENV in str(exc_info.value)
assert settings.SECRETS_ENCRYPTION_KEY == ""
def test_ensure_secrets_recovers_when_another_process_wrote_the_key_first(
monkeypatch, secrets_dir
):
winner = _throwaway_fernet_key()
real_open = os.open
def _lose_the_race(path, flags, *args):
if Path(path).name == ENCRYPTION_KEY_FILE:
(secrets_dir / ENCRYPTION_KEY_FILE).write_text(winner)
return real_open(path, flags, *args)
monkeypatch.setattr(api_apps_module.os, "open", _lose_the_race)
_make_app()._ensure_secrets()
assert settings.SECRETS_ENCRYPTION_KEY == winner
def test_ensure_secrets_generates_secret_key_when_empty(monkeypatch, secrets_dir):
monkeypatch.setattr(
settings, "SECRETS_ENCRYPTION_KEY", _throwaway_fernet_key(), raising=False
)
_make_app()._ensure_secrets()
generated = (secrets_dir / SECRET_KEY_FILE).read_text()
assert len(generated) == 50
assert settings.SECRET_KEY == generated
assert os.environ[SECRET_KEY_ENV] == generated
def test_ensure_secrets_keeps_explicit_secret_key(monkeypatch, secrets_dir):
monkeypatch.setattr(
settings, "SECRETS_ENCRYPTION_KEY", _throwaway_fernet_key(), raising=False
)
monkeypatch.setenv(SECRET_KEY_ENV, "explicit-throwaway-secret-key")
_make_app()._ensure_secrets()
assert not (secrets_dir / SECRET_KEY_FILE).exists()
def test_ensure_secrets_is_skipped_while_testing(monkeypatch, secrets_dir):
monkeypatch.setattr(settings, "TESTING", True, raising=False)
_make_app()._ensure_secrets()
assert not (secrets_dir / ENCRYPTION_KEY_FILE).exists()
assert not (secrets_dir / SECRET_KEY_FILE).exists()
def test_import_models_settles_secrets_before_loading_models(monkeypatch):
calls = []
monkeypatch.setattr(
ApiConfig, "_ensure_secrets", lambda self: calls.append("secrets")
)
monkeypatch.setattr(AppConfig, "import_models", lambda self: calls.append("models"))
_make_app().import_models()
assert calls == ["secrets", "models"]
+1 -1
View File
@@ -9,7 +9,7 @@ from config.settings.sentry import * # noqa
from config.settings.social_login import * # noqa
from django.core.exceptions import ImproperlyConfigured
SECRET_KEY = env("SECRET_KEY", default="secret")
SECRET_KEY = env("SECRET_KEY", default="")
DEBUG = env.bool("DJANGO_DEBUG", default=False)
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+2
View File
@@ -18,6 +18,8 @@ DATABASES = {
DATABASE_ROUTERS = []
TESTING = True
# `_ensure_secrets` is skipped while testing, and Django rejects an empty SECRET_KEY
SECRET_KEY = env("SECRET_KEY", default="insecure-testing-secret-key-do-not-use-in-prod")
# Override page size for testing to a value only slightly above the current fixture count.
# We explicitly set PAGE_SIZE to 15 (round number just above fixture) to avoid masking pagination bugs, while not setting it excessively high.
# If you add more providers to the fixture, please review that the total value is below the current one and update this value if needed.
+9 -3
View File
@@ -46,6 +46,9 @@ services:
ui:
image: prowlercloud/prowler-ui:${PROWLER_UI_VERSION:-stable}
restart: unless-stopped
environment:
# No default is shipped: abort `docker compose up` instead of crash-looping the UI
- AUTH_SECRET=${AUTH_SECRET:?generate it with openssl rand -base64 32 and set it in .env}
env_file:
- path: .env
required: false
@@ -75,7 +78,7 @@ services:
- path: .env
required: false
ports:
- "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}"
- "127.0.0.1:${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER} -d ${POSTGRES_DB}'"]
interval: 5s
@@ -92,7 +95,7 @@ services:
- path: .env
required: false
ports:
- "${VALKEY_PORT:-6379}:6379"
- "127.0.0.1:${VALKEY_PORT:-6379}:6379"
healthcheck:
test: ["CMD-SHELL", "sh -c 'valkey-cli ping'"]
interval: 10s
@@ -125,7 +128,7 @@ services:
# Networking
- "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}"
ports:
- ${NEO4J_PORT:-7687}:7687
- "127.0.0.1:${NEO4J_PORT:-7687}:7687"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"]
interval: 10s
@@ -141,6 +144,7 @@ services:
- path: .env
required: false
volumes:
- ./_data/api:/home/prowler/.config/prowler-api
- "output:/tmp/prowler_api_output"
depends_on:
api:
@@ -159,6 +163,8 @@ services:
env_file:
- path: ./.env
required: false
volumes:
- ./_data/api:/home/prowler/.config/prowler-api
depends_on:
api:
condition: service_healthy
+2
View File
@@ -55,6 +55,8 @@ The Prowler CLI image is also available on AWS Public ECR: [`public.ecr.aws/prow
```console
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/docker-compose.yml
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/.env
# AUTH_SECRET is not shipped: set it in .env before the first start
perl -pi -e "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env
docker compose up -d
```
@@ -26,6 +26,8 @@ Refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app) for detail
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/.env"
# AUTH_SECRET is not shipped: set it in .env before the first start
perl -pi -e "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env
docker compose up -d
```
@@ -34,12 +36,17 @@ Refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app) for detail
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
# AUTH_SECRET is not shipped: set it in .env before the first start
$AuthSecret = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
(Get-Content .env) -replace '^AUTH_SECRET=.*', "AUTH_SECRET=`"$AuthSecret`"" | Set-Content .env
docker compose up -d
```
</CodeGroup>
<Callout icon="lock" iconType="regular" color="#e74c3c">
For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
The API also generates `DJANGO_SECRETS_ENCRYPTION_KEY` on the first start, stored next to the key pair as `secrets_encryption.key`. Unlike the key pair, this one cannot be rotated by deleting the file: it protects every stored credential. See [Secrets encryption key](#secrets-encryption-key).
</Callout>
</Tab>
@@ -148,9 +155,19 @@ The `--policy always` flag ensures that Docker pulls the latest images even if t
<Note>
**What Gets Preserved During Upgrade**
Everything is preserved, nothing will be deleted after the update.
Everything is preserved, nothing will be deleted after the update. The `_data/` directory holds the database, the graph store and the keys under `_data/api`, including `secrets_encryption.key`: never remove it when upgrading.
</Note>
### Secrets encryption key
`DJANGO_SECRETS_ENCRYPTION_KEY` is the symmetric key that encrypts every cloud provider credential, integration credential and LLM key stored by Prowler. When the variable is empty, the API generates the key on the first start and persists it as `_data/api/secrets_encryption.key` (`~/.config/prowler-api/secrets_encryption.key` outside a container), where the `api`, `worker` and `worker-beat` containers read it. To manage it yourself, set the variable to the output of `openssl rand -base64 32` before the first start.
<Warning>
Keep this key across upgrades and back it up together with the database. Rotating it means re-encrypting every stored credential: Prowler does not do that automatically, so setting a new key leaves the existing credentials unreadable and they have to be entered again.
</Warning>
Older `.env` files shipped a working value for this variable, so every installation that kept it shares the same public key. The API now refuses to start with it and logs `DJANGO_SECRETS_ENCRYPTION_KEY is set to a value that was published in the Prowler repository`. To recover, clear the variable in `.env` (or set a freshly generated one), restart the stack and enter the provider, integration and LLM credentials again; they were encrypted with a public key and must be considered exposed.
### Troubleshooting Installation Issues
If containers don't start, check logs for errors: