> ## Documentation Index
> Fetch the complete documentation index at: https://voicera.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Security hardening

> What to change before exposing a VoicEra deployment.

VoicEra ships development defaults so the stack starts on the first try. Work through this page before anything is reachable beyond your laptop.

## Data ownership

Everything VoicEra stores — call recordings, transcripts, knowledge-base documents, provider credentials — stays in the Postgres, MinIO, and FerretDB containers you run. Nothing is sent to a VoicEra-operated service, and there is no telemetry or usage reporting baked into the platform. The only external network calls the stack makes are the ones you configure yourself: your chosen STT/TTS/LLM providers, and your telephony provider. Self-hosting every model (see [Self-hosted models](self-hosted-models)) removes those too, for a fully air-gapped deployment.

## Change every default

All of these are public knowledge — they are in `.env.example` and `docker-compose.yaml`.

| Variable                                   | Ships as        | Protects                                       |
| ------------------------------------------ | --------------- | ---------------------------------------------- |
| `MONGODB_PASSWORD`                         | `admin123`      | The database, **and** the PostgreSQL superuser |
| `MINIO_ROOT_PASSWORD` / `MINIO_SECRET_KEY` | `minioadmin123` | Recordings and transcripts                     |
| `MINIO_ROOT_USER` / `MINIO_ACCESS_KEY`     | `minioadmin`    | Same                                           |
| `REDIS_PASSWORD`                           | `redissecret`   | Job queue, campaign events, concurrency slots  |

<Warning>
  `MONGODB_USER` and `MONGODB_PASSWORD` become the PostgreSQL credentials too. Changing them after the volume exists does **not** update the Postgres user — set them before the first start, or change the password inside Postgres as well.
</Warning>

## The three generated secrets

`make application-up` (which wraps `./scripts/start-application-services.sh`) generates these when blank and never overwrites them.

| Secret                         | Protects                                | Rotation                                   |
| ------------------------------ | --------------------------------------- | ------------------------------------------ |
| `SECRET_KEY`                   | JWT signatures (HS256)                  | Invalidates all tokens; users log in again |
| `INTERNAL_API_KEY`             | Service-to-service auth                 | Update API and runtime together            |
| `PROVIDER_AUTH_ENCRYPTION_KEY` | Fernet encryption of stored credentials | **One-way — see below**                    |

<Warning>
  `PROVIDER_AUTH_ENCRYPTION_KEY` cannot be rotated. There is no re-encryption tool: changing or losing it makes every stored provider credential permanently undecryptable, and every organisation must re-enter every key. Back it up alongside the database, and store it with the same protection.
</Warning>

<Warning>
  If `SECRET_KEY` is blank the API does **not** fail. `apps/api/app/auth.py` logs a warning and generates a temporary key at import — so tokens die on every restart and replicas reject each other's tokens. Verify it is set:

  ```bash theme={null}
  grep '^SECRET_KEY=' .env
  ```
</Warning>

Generate:

```bash theme={null}
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```

Keep them out of files on disk in production — inject from a secret manager. Every variable the stack reads, not just the security-relevant ones, is in [Environment variables](../../developer/reference/environment-variables).

## CORS

`apps/api/app/main.py` sets:

```python theme={null}
allow_origins=["*"], allow_credentials=True
```

Any origin can call the API with credentials. Restrict it to the origins that actually need it and rebuild.

## The internal API key

`INTERNAL_API_KEY` is a **single shared credential with organisation-wide reach**. `POST /users/bot/token` exchanges it plus an `org_id` for a token with role `admin` in that organisation — for any organisation.

| Rule                                            | Why                             |
| ----------------------------------------------- | ------------------------------- |
| Never send it from a browser                    | It is not a user credential     |
| Never expose the routes that accept it publicly | They bypass user auth by design |
| Rotate on any suspicion                         | One value protects every tenant |

## The unauthenticated runtime endpoints

<Warning>
  `GET|POST /answer` and `WS /agent/{org_id}/{agent_id}` have **no authentication**. They must be publicly reachable for telephony to work, and the runtime resolves everything from the path — so anyone who learns an org and agent id pair can open a pipeline session and spend your model credits.
</Warning>

Mitigations:

* Rate limit `/answer` and `/agent` at the proxy.
* IP-allowlist your telephony provider's published ranges.
* Treat org and agent ids as semi-secret — do not put them in public pages or client-side code.
* Monitor call volume for unexplained sessions.

## Network exposure

Publish only what must be public:

| Service               | Expose                                                     |
| --------------------- | ---------------------------------------------------------- |
| API `:8000`           | Behind TLS, to your clients                                |
| Runtime `:7860`       | Behind TLS, to your telephony provider                     |
| FerretDB `:27018`     | **Never publicly.** Bind to localhost or drop the mapping. |
| MinIO `:9000`         | Private                                                    |
| MinIO console `:9001` | **Never publicly.** It is an admin UI.                     |
| Model gateway `:8100` | Private only — it has no authentication                    |
| PostgreSQL, Redis     | Already unpublished; keep it that way                      |

To stop publishing a port, remove its `ports:` entry or bind it to loopback:

```yaml theme={null}
ports:
  - "127.0.0.1:27018:27017"
```

## TLS

Telephony providers require HTTPS for webhooks and WSS for audio, so TLS is mandatory rather than optional. Terminate at a reverse proxy — see [Production deployment](production) for a working nginx configuration, including the WebSocket upgrade headers and the long read timeouts calls need.

For Redis over TLS use a `rediss://` URL; the ARQ settings enable TLS when they see that scheme.

## Email enumeration

<Warning>
  `GET /users/check/{email}` is **public and unauthenticated**, and confirms whether an account exists. Rate limit it at the proxy, or require authentication if you do not need the invite-flow convenience.
</Warning>

## Health probes

`GET /health` returns HTTP **200 even when the database is down** — only the body changes to `"status": "degraded"`. Configure probes to parse the body, or a broken API will look healthy.

## Images and dependencies

* `minio/minio:latest` is unpinned — pin a digest for reproducible deployments. The FerretDB, Postgres, and Redis images are already pinned.
* Rebuild periodically to pick up base-image security updates.
* VoicEra has no CI, so nothing scans dependencies automatically. Run `pip-audit` or equivalent yourself.

## Log hygiene

Logs go to `json-file`, rotating at 10 MB with three files kept. Before shipping them anywhere central, confirm no provider keys or tokens appear — and note that `DEBUG=True` substantially increases what is logged. Keep it `False` in production.

## Data protection

You hold call recordings, transcripts, and contact lists. That is regulated data in most jurisdictions.

* Encrypt volumes at rest.
* Set a retention policy — nothing expires automatically.
* Restrict MinIO access; artifacts are served through the authenticated API proxy, so the bucket never needs to be public.
* Remember `docker compose down -v` destroys all of it irreversibly.

## Checklist

* [ ] `MONGODB_PASSWORD` changed
* [ ] `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` changed
* [ ] `REDIS_PASSWORD` changed
* [ ] `SECRET_KEY` set and non-empty
* [ ] `INTERNAL_API_KEY` set and non-empty
* [ ] `PROVIDER_AUTH_ENCRYPTION_KEY` set and backed up
* [ ] `SECRET_KEY` identical across API replicas
* [ ] CORS restricted
* [ ] TLS on the API and runtime
* [ ] FerretDB, MinIO console, and the model gateway not publicly reachable
* [ ] Rate limiting on `/answer`, `/agent`, and `/users/check`
* [ ] `DEBUG=False`
* [ ] Volumes encrypted, retention policy set
* [ ] Backups tested by restoring

## Related

* [Production deployment](production)
* [Generated secrets and defaults](../quickstart/secrets-and-defaults)
* [Environment variables](../../developer/reference/environment-variables)
* [Provider credentials](../../developer/reference/provider-auth)
* [Multi-tenancy and roles](../../developer/reference/multi-tenancy)
