Configuration & secrets
How configuration and secrets reach each host without ever living in git — host .env files, Jenkins-injected credentials, build-time Console vars, and which secret belongs to which service.
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:
- Source control holds code and
.env.exampleonly. The example file documents every variable a service expects, with placeholder values. The real.envis never committed and is listed in.gitignore. - 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
.envas 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.
| Service | Host role | Env file | Loaded by |
|---|---|---|---|
| Backend API | API/App host | /opt/voxbridge/.env | voxbridge systemd service (uvicorn on 8080) |
| Voice fleet | Fleet host(s) | /opt/voxcore/.env | voxcore@.service (one instance per worker socket) |
| Dialler | SIP/LiveKit host | /opt/voxdialler/.env | voxdialler.service |
| Console | API/App host | build-time .env only | the 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=alwaysTip
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.
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.
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.
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.
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: 'ori-jwt-secret', variable: 'JWT_SECRET'),
string(credentialsId: 'ori-mongodb-uri', variable: 'MONGODB_URI'),
string(credentialsId: 'ori-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 intodist/duringnpm 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 Ori's single-brand repo the brand
.envis committed at the brand path and the build copies it to the root.env, so the build is simplynpm 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.
Tip
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_DBisvoxbridge.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.
Set in /opt/voxcore/.env (shared by all worker instances on the host).
- STT —
SONIOX_API_KEY,DEEPGRAM_API_KEY - LLM —
OPENAI_API_KEY,GOOGLE_API_KEY - TTS —
ELEVENLABS_API_KEY,SARVAM_API_KEY - Object storage —
MINIO_ACCESS_KEY,MINIO_SECRET_KEY(withMINIO_ENDPOINT,MINIO_BUCKET,MINIO_SECURE) for recording upload - OTEL —
OTEL_API_KEY/OTEL_HEADERSwhen tracing is enabled
The fleet also reaches the Backend at VOXBRIDGE_CONFIG_URL to fetch per-call
runtime config; that URL is config, not a secret.
Set in /opt/voxdialler/.env.
MONGODB_URI— same database as the Backend (MONGODB_DB=voxbridge)LIVEKIT_API_KEY,LIVEKIT_API_SECRET— authenticate to LiveKit to place SIP calls (withLIVEKIT_URL,LIVEKIT_TRUNK_ID)VOXCORE_SECRET— shared secret used to authenticatePOST /attachwhen the dialler hands an answered call to a fleet worker
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
| Secret | Used by | Where it's set |
|---|---|---|
JWT_SECRET | Backend API | /opt/voxbridge/.env |
MONGODB_URI | Backend API, Dialler | /opt/voxbridge/.env, /opt/voxdialler/.env |
REDIS_URL | Backend 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_KEY | Voice fleet | /opt/voxcore/.env |
OTEL_API_KEY | Voice fleet (when tracing on) | /opt/voxcore/.env |
LIVEKIT_API_KEY / LIVEKIT_API_SECRET | Dialler (LiveKit) | /opt/voxdialler/.env |
VOXCORE_SECRET | Dialler + Voice fleet (shared) | /opt/voxdialler/.env and /opt/voxcore/.env |
VITE_* (brand/config, not secret) | Console | build-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_LEVELatINFOin production and never echo.envin deploy scripts. Connection strings that embed passwords (MONGODB_URI,REDIS_URL) are secrets too — treat them the same way.
The general rotation procedure:
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.
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.
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.
Deployment
How a release lands on the servers — the host roles, the systemd units that run each service, and how Jenkins checks out, builds, and restarts them.
Operations runbook
The day-2 runbook for keeping Ori healthy in production — health checks, service control, rollback, scaling, and a triage table for the incidents you will actually hit.