Every service reads its configuration from environment variables, and every secret those services need — database credentials, the JWT signing key, provider API keys, storage and LiveKit keys — lives only on the host that runs the service. Nothing sensitive is ever committed to git. This page explains the model: how config lands on a host, where the secrets come from at deploy time, and which secret each service actually uses.
This page is about where secrets live and how they get there. For the full annotated variable list per service — every key, its default, and what it does — see the developer configuration page.

The model in one picture

Two rules follow from this diagram and they are the whole policy:
  1. Source control holds code and .env.example only. The example file documents every variable a service expects, with placeholder values. The real .env is never committed and is listed in .gitignore.
  2. Secrets enter at deploy time, not commit time. Jenkins pulls the real values from its credential store (or from Bitbucket repository / deployment variables) and writes them into the host’s .env as the deploy step runs.

Each host gets a /opt/<service>/.env

Every Python service is started by systemd, and each unit points at a single EnvironmentFile on that host. The service reads its configuration from that file and nothing else — there are no config values hardcoded in the image, and no shared cluster config service.
ServiceHost roleEnv fileLoaded by
Backend APIAPI/App host/opt/voxbridge/.envvoxbridge systemd service (uvicorn on 8080)
Voice fleetFleet host(s)/opt/voxcore/.envvoxcore@.service (one instance per worker socket)
DiallerSIP/LiveKit host/opt/voxdialler/.envvoxdialler.service
ConsoleAPI/App hostbuild-time .env onlythe Vite build — not at runtime (see below)
The systemd units reference the file with EnvironmentFile=, so the values are loaded into the process environment at start. The fleet’s templated unit is the only subtle one — a single .env on the host is shared by every worker instance, and the per-worker socket comes from the instance specifier, not from a secret:
# /etc/systemd/system/voxcore@.service (illustrative)
[Service]
Type=simple
EnvironmentFile=/opt/voxcore/.env
Environment=VOXCORE_SOCKET=/tmp/voxcore_%i.sock
ExecStart=/opt/voxcore/scripts/run_worker.sh
Restart=always
Because the env file is read at process start, editing /opt/<service>/.env does nothing until you restart the unit. After any secret change, restart the affected service: systemctl restart voxbridge, systemctl restart 'voxcore@*', or systemctl restart voxdialler.

How Jenkins injects secrets at deploy time

The deploy stage of the pipeline is the only place real secrets touch a host. On a green build of main, Jenkins connects to each target host, refreshes the code, syncs dependencies, renders the host .env from credentials, then restarts the service. See the pipeline and deployment pages for the full flow; here is just the secret-handling part.
1

Secrets live in the credential store

Provider keys, the JWT secret, database URIs and LiveKit keys are stored as Jenkins Credentials (secret text / secret file), or as Bitbucket repository / deployment variables for repo-scoped values. They are never in the repo and never printed in build logs.
2

The deploy step binds them as variables

The pipeline binds the credentials it needs into the deploy step’s environment, so they exist only for the duration of that step on the build agent.
3

Jenkins writes the host .env

The deploy step writes (or links) /opt/<service>/.env on the target host from those bound values — typically by rendering a template, or by copying a per-host secret file. The file is owned by the service user and mode 600.
4

Restart picks up the new config

systemctl restart reloads the unit, which re-reads EnvironmentFile, and the post-deploy health check confirms the service came up with the new configuration.
A declarative Jenkinsfile fragment, illustrative only — the real values come from the credential IDs, never from the file:
stage('Deploy Backend') {
  steps {
    withCredentials([
      string(credentialsId: 'pelocal-jwt-secret',   variable: 'JWT_SECRET'),
      string(credentialsId: 'pelocal-mongodb-uri',  variable: 'MONGODB_URI'),
      string(credentialsId: 'pelocal-redis-url',    variable: 'REDIS_URL'),
    ]) {
      sh '''
        ssh deploy@api-host '
          cd /opt/voxbridge &&
          git fetch --all && git checkout "$RELEASE_TAG" &&
          uv sync &&
          umask 077 &&
          cat > /opt/voxbridge/.env <<EOF
MONGODB_URI=${MONGODB_URI}
MONGODB_DB=voxbridge
REDIS_URL=${REDIS_URL}
JWT_SECRET=${JWT_SECRET}
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
VOXCORE_WSS_BASE_URL=wss://voice.example.com/ws
LOG_LEVEL=INFO
EOF
          systemctl restart voxbridge
        '
      '''
    }
  }
}
Keep .env.example in the repo accurate. It is the contract for what Jenkins must supply — if a new variable is added to a service, add it to .env.example in the same change so the deploy template and the credential store stay in step. A missing required variable will stop the Backend from booting.

Console secrets are build-time, not runtime

The Console is a static single-page app. It has no runtime .env — its configuration is baked into the JavaScript bundle by Vite at build time through VITE_* variables. This is a different and important rule:
  • The build reads brand variables (VITE_API_URL, VITE_BRAND_NAME, theme colours, the token storage key, etc.) and substitutes them into dist/ during npm run build.
  • The output is served as static files by nginx. There is nothing to inject after the build; whatever was set at build time is permanent in that bundle.
  • For Pelocal’s single-brand repo the brand .env is committed at the brand path and the build copies it to the root .env, so the build is simply npm install && npm run build.
Never put a server secret in a VITE_* variable. Anything prefixed VITE_ is inlined into the public JavaScript bundle and is readable by anyone who opens the app. VITE_* values are non-secret brand/config only (API base URL, brand name, colours). The JWT secret, provider API keys, database and LiveKit credentials belong on the Backend / fleet / dialler hosts — never in the Console build.
Because branding is build-time, the Console build must run with the correct brand .env for the deployment. A build run against the wrong brand env produces a bundle pointing at the wrong API URL or showing the wrong branding — caught by the post-deploy check that the served app talks to the expected Backend.

The secrets that matter, per service

These are the sensitive values each service needs. Non-secret config (ports, log levels, intervals, sample rates) is covered on the developer configuration page.
Set in /opt/voxbridge/.env.
  • JWT_SECRET — signs and verifies operator session tokens. The single most sensitive value in the platform.
  • MONGODB_URI — connection string (may embed user/password) for the durable database. MONGODB_DB is voxbridge.
  • REDIS_URL — connection string for queues and runtime cache.
Provider API keys and storage/LiveKit keys used by calls are configured for the runtime services that actually make provider calls (the fleet and dialler), plus any keys the Backend stores in system settings to hand to the fleet as runtime config.

The shared VOXCORE_SECRET

VOXCORE_SECRET is the one secret that must be identical on two services: the dialler presents it and the fleet verifies it. When the dialler attaches an answered call to a free fleet worker over POST /attach, the fleet rejects the request unless the secret matches. If you rotate it, rotate it on the fleet hosts and the dialler host in the same window, or attaches will start failing.

Secret → service → where it’s set

SecretUsed byWhere it’s set
JWT_SECRETBackend API/opt/voxbridge/.env
MONGODB_URIBackend API, Dialler/opt/voxbridge/.env, /opt/voxdialler/.env
REDIS_URLBackend API/opt/voxbridge/.env
SONIOX_API_KEY / DEEPGRAM_API_KEY (STT)Voice fleet/opt/voxcore/.env
OPENAI_API_KEY / GOOGLE_API_KEY (LLM)Voice fleet/opt/voxcore/.env
ELEVENLABS_API_KEY / SARVAM_API_KEY (TTS)Voice fleet/opt/voxcore/.env
MINIO_ACCESS_KEY / MINIO_SECRET_KEYVoice fleet/opt/voxcore/.env
OTEL_API_KEYVoice fleet (when tracing on)/opt/voxcore/.env
LIVEKIT_API_KEY / LIVEKIT_API_SECRETDialler (LiveKit)/opt/voxdialler/.env
VOXCORE_SECRETDialler + Voice fleet (shared)/opt/voxdialler/.env and /opt/voxcore/.env
VITE_* (brand/config, not secret)Consolebuild-time .env, baked into dist/

Rotating secrets safely

Secrets are not set once and forgotten. When you rotate one, do it deliberately and restart the services that read it.
Rotate the high-blast-radius secrets with care, and never log them.
  • JWT_SECRET — rotating it invalidates every existing operator session, so every user is signed out and must log back in. Plan it; don’t do it casually mid-day.
  • Provider API keys (STT / LLM / TTS) — a rotation that leaves a stale key on even one fleet host means calls on that host fail at the provider call. Update the key on every fleet host, then restart all workers there.
  • VOXCORE_SECRET — must change on the fleet and the dialler together, or attaches break (see above).
  • Never write any of these to logs, error messages, or build output. Keep LOG_LEVEL at INFO in production and never echo .env in deploy scripts. Connection strings that embed passwords (MONGODB_URI, REDIS_URL) are secrets too — treat them the same way.
The general rotation procedure:
1

Update the credential store

Change the value in Jenkins Credentials (or the Bitbucket variable). The repo and .env.example do not change — only the stored secret.
2

Redeploy the affected service(s)

Re-run the deploy job so Jenkins rewrites /opt/<service>/.env on the target hosts with the new value. For a shared secret like VOXCORE_SECRET, redeploy both the fleet and the dialler.
3

Restart and verify

The deploy restarts the unit (systemctl restart …). Confirm the service came back with the post-deploy health checks: Backend GET /health, fleet GET /health/fleet, dialler GET /health on :8090. See the runbook.

Where to go next

Full variable reference

Every environment variable for each service, with defaults and meaning.

The deploy pipeline

Bitbucket → Jenkins: build, test, and the deploy stage that writes the host .env.

Host deployment

Server roles, systemd units, and how each service is laid out on its host.

Operations runbook

Restart commands, health checks, and rollback after a rotation goes wrong.