Backend Configuration
The backend is configured through a required config.yaml file, loaded at
startup with koanf (see internal/config).
This page is the canonical reference for every key. The authoritative,
fully-commented source is backend/config.example.yaml in the
repo — copy it to backend/config.yaml for
local development (make backend-run / make backend-run-dev do this
automatically if config.yaml is missing).
How configuration is loaded
Section titled “How configuration is loaded”- Path resolution. The config file path is taken from the
--configflag, then theVIBEXP_CONFIG_FILEenvironment variable, then./config.yaml. - Fail-fast. The file is required: a missing file aborts startup with
an error naming the expected path and pointing at
config.example.yaml. - Defaults, then file. Code-level defaults are merged first; the YAML file
overrides any of them, and the result is unmarshalled into a nested
Configstruct (the koanf tags name each section/key). - Interpolation.
${VAR}references in the file’s string values are resolved against the process environment (see the grammar below). - Validation. Invariants are checked before the server starts — the
service fails closed on a bad config rather than running misconfigured.
Sections removed in past releases fail loudly: a leftover top-level
github:block aborts startup with guidance to re-register the App per team (removed in v0.9.0), while a leftoverembedding:block is silently ignored.
The published Docker image bakes a production-neutral default config
(config.docker.yaml) at /app/config.yaml and sets VIBEXP_CONFIG_FILE
accordingly, so docker run works with environment variables alone; mount your
own file over that path to take full control.
Interpolation grammar
Section titled “Interpolation grammar”Interpolation applies to string values only and operates on the parsed YAML tree, so it can never corrupt YAML syntax:
| Expression | Resolves to |
|---|---|
${VAR} | The value of environment variable VAR. Unset → empty string plus a startup warning. |
${VAR:-default} | VAR when set and non-empty, otherwise default. |
$${literal} | A literal ${literal} — the $$ escapes interpolation. |
Secrets live in .env, structure lives in config.yaml
Section titled “Secrets live in .env, structure lives in config.yaml”config.yaml holds all non-secret settings as literal values. Secrets are
referenced as ${VAR} and resolved from the environment at load time — keep
them out of the file and out of git. backend/.env.example lists every ${VAR}
secret (database password, encryption key, session key, provider client
secrets, email keys); make backend-run* loads .env into the environment so
the references resolve.
The JSON schema
Section titled “The JSON schema”backend/config.schema.json is a committed JSON schema generated from the
nested Config struct — it gives editors (VS Code / JetBrains via the YAML
language server) validation and autocomplete for config.yaml. Both
config.example.yaml and config.docker.yaml declare it via a
yaml-language-server header comment.
make backend-generate-config-schema # regenerate via go run ./cmd/gen-config-schemamake backend-config-schema-check # regenerate, then fail if it differs from the committed fileCI runs backend-config-schema-check, so regenerate and commit the schema
whenever you change the Config struct.
Server
Section titled “Server”| Key | Default | Purpose |
|---|---|---|
server.port | 8080 | HTTP listen port. |
server.log_level | info | debug | info | warn | error. Use debug locally. |
server.log_format | json | json (structured, for aggregators) or text (human-readable). |
server.service_version | dev | Version tag attached to metrics/traces. Release builds inject it. |
server.release_sha / server.release_date | dev / unknown | Build metadata. |
server.max_body_size_bytes | 10485760 (10 MiB) | Global request-body cap (memory-exhaustion backstop). Webhooks and the OAuth AS JSON endpoints cap at 64 KiB. |
server.cors_allowed_origins | (empty) | Allowed CORS origins. Empty allows only the localhost dev origins (:5173, :5174); only needed when the SPA is served from a different origin than the API. |
server.trusted_proxies | (empty) | CIDRs of trusted reverse proxies. Empty ignores X-Forwarded-For/X-Real-IP, so the connecting peer is the client IP. Behind a proxy, list its CIDR(s) (e.g. TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12) or every client collapses into one rate-limit bucket. Malformed CIDRs fail startup. |
server.error_type_base_uri | about:blank | RFC 9457 error type base URI (joined as <base>/<code>). |
Database
Section titled “Database”| Key | Default | Required | Purpose |
|---|---|---|---|
database.host | localhost | Yes | Postgres host. May be a Unix socket path (Cloud SQL) when it starts with /. |
database.port | 5432 | Yes | Postgres port. |
database.user | vibexp_app | Yes | Database user. |
database.password | ${DB_PASSWORD} | Yes | Database password (secret — resolved from the environment). |
database.name | vibexp_io | Yes | Database name. |
database.sslmode | disable | No | Connection TLS mode. disable (no TLS) or require (encrypt, no cert verification). Set require for managed Postgres that mandates TLS. |
See Database & Migrations for pooling and migration behaviour.
Security & encryption
Section titled “Security & encryption”| Key | Default | Required | Purpose |
|---|---|---|---|
security.encryption_key | ${ENCRYPTION_KEY} | Yes | AES-256 key for encrypting sensitive data at rest (API keys, OAuth-AS signing keys). Must be exactly 32 bytes. |
security.api_key_common | ${API_KEY_COMMON} | No | Shared API key for the common API surface. |
security.backoffice_admin_api_key | ${BACKOFFICE_ADMIN_API_KEY} | No | Admin key for back-office endpoints (/bo/*). Separate from JWTs and regular API keys. |
security.outbound_allowed_cidrs | [] (env ${OUTBOUND_ALLOWED_CIDRS}, comma-separated) | No | Networks the SSRF guard may dial even though they are loopback, private, or IPv6 unique-local. Empty refuses every reserved range. |
Outbound network policy
Section titled “Outbound network policy”The SSRF guard covers every destination a team can point VibeXP at: embedding
providers, model providers, A2A agent endpoints, and team-supplied SMTP or
Mailgun hosts. By default it refuses loopback, private (RFC 1918), and IPv6
unique-local addresses, because a team member could otherwise aim one of those
endpoints at your internal network and read back the response. GitHub App calls
are not affected; they always talk to a hardcoded https://api.github.com.
That default is what makes a self-hosted embedding or model sidecar unreachable. TEI, Ollama, and llama.cpp typically sit on a private Docker subnet or on localhost, so declare the range:
security: outbound_allowed_cidrs: ["172.16.0.0/12"]or, in the combined image, OUTBOUND_ALLOWED_CIDRS=172.16.0.0/12 (comma-separate
several). Common values: 172.16.0.0/12 for Docker bridge networks,
127.0.0.1/32 for a same-host Ollama, 10.42.0.0/24 for your own cluster
subnet.
The list only widens the loopback/private tier. Link-local (169.254.0.0/16,
fe80::/10, which covers the cloud-metadata address) and multicast
(224.0.0.0/4, ff00::/8) stay blocked no matter what, and an entry that
overlaps them fails startup, so 0.0.0.0/0 cannot be used to switch the guard
off. A malformed CIDR fails startup too. Local development needs none of this: a
localhost frontend.base_url already permits reserved destinations.
Authentication
Section titled “Authentication”Selects and configures the web-login identity providers. See Authentication for the full model.
| Key | Default | Purpose |
|---|---|---|
auth.providers | [] | Web-login providers to enable simultaneously: any of google, github, oidc (case-insensitive). Empty disables web login (dev login still works). Takes precedence over auth.provider. |
auth.provider | (empty) | Backward-compatible single-provider shim; used only when auth.providers is empty. |
auth.session_encryption_key | ${SESSION_ENCRYPTION_KEY} | Hex secret (64 hex chars = 32 bytes) backing the AES-256-GCM session cookie and the OAuth state HMAC. Empty disables cookie session auth. Generate with openssl rand -hex 32. |
auth.dev_login_enabled | false when unset (the shipped config.example.yaml and baked Docker config set true) | Gates /api/v1/auth/dev/login. Effective only when true and the environment is detected as local development. |
auth.access_allowlist.domains | [] | Allowed email domains (matched exactly against the part after the last @). |
auth.access_allowlist.emails | [] | Allowed exact email addresses. A user may sign in if either list matches; both empty means open registration. While active, sign-in also requires a provider-verified email (unverified accounts are denied). |
auth.instance_admins | [] | Emails designated as instance administrators. Drives is_instance_admin and the /api/v1/admin portal; matched case-insensitively and validated as emails at startup. |
Per-provider blocks
Section titled “Per-provider blocks”Each provider has its own block; set the secret in .env and the rest in
config.yaml. All redirect URIs default to
http://localhost:8080/api/v1/auth/callback.
| Key | Purpose |
|---|---|
auth.google.client_id / auth.google.client_secret / auth.google.redirect_uri | Google OIDC web-login client (reached directly via accounts.google.com discovery). |
auth.github.client_id / auth.github.client_secret / auth.github.redirect_uri | GitHub OAuth2 web-login client (OAuth2, not OIDC; claims come from the GitHub REST API). |
auth.oidc.issuer_url / auth.oidc.client_id / auth.oidc.client_secret / auth.oidc.redirect_uri | Generic OIDC client for any compliant issuer (Keycloak, Authentik, Zitadel, Auth0, …). issuer_url drives /.well-known/openid-configuration discovery. |
Embedded OAuth 2.1 Authorization Server (auth.oauth_as)
Section titled “Embedded OAuth 2.1 Authorization Server (auth.oauth_as)”The AS mints the bearer JWTs the MCP endpoint accepts. When issuer_url is set
the AS is mounted; empty disables it. In local development it auto-enables
at http://localhost:<server.port> even when left empty. See
Authentication → Embedded OAuth 2.1 Authorization Server.
| Key | Default | Purpose |
|---|---|---|
auth.oauth_as.issuer_url | (empty; auto-derived locally) | Public base URL of the AS — becomes the token iss and the metadata issuer. Must be HTTPS in production. |
auth.oauth_as.access_token_ttl | 15m | Access-token lifetime. |
auth.oauth_as.refresh_token_ttl | 720h | Refresh-token lifetime. Must exceed the access-token TTL. |
auth.oauth_as.auth_code_ttl | 10m | Authorization-code lifetime. |
auth.oauth_as.key_rotation_interval | 720h | How often the RS256 signing key rotates (coordinated across replicas). |
auth.oauth_as.cleanup_interval | 1h | How often expired codes, tokens, PKCE and login sessions, and retired signing keys are pruned. |
API-surface OAuth (auth.api_oauth)
Section titled “API-surface OAuth (auth.api_oauth)”/api/v1/* accepts issuer-signed bearer JWTs (native PKCE clients, the CLI’s
vibexp auth login) alongside session cookies and API keys. These keys are
auto-wired when the embedded AS is enabled and are override-only, not
required for the embedded-AS path:
- Embedded AS (auto). When
auth.oauth_as.issuer_urlis set (including the local-dev auto-enable) andauth.api_oauth.issueris left empty, VibeXP auto-derives both:issueris set to the AS issuer andaudiencesis pinned to[mcp.resource_uri](require that audience). Native-CLI login works on/api/v1with no manual config. - External IdP (manual override). Set
auth.api_oauth.issuerexplicitly to point/api/v1at an external issuer; an explicit issuer always wins over the auto-wiring.
| Key | Default | Purpose |
|---|---|---|
auth.api_oauth.issuer | (empty) | Issuer URL. When the embedded AS is enabled, empty auto-fills with the AS issuer, so bearer JWTs are accepted. Only when the AS is also disabled does empty reject non-API-key bearer tokens with 401. Set explicitly to override with an external IdP. |
auth.api_oauth.audiences | [] | aud allow-list. When the embedded AS is enabled and this is empty, it auto-pins to [mcp.resource_uri] (require that audience) so the CLI’s AS-issued token is accepted. With an external IdP issuer and this left empty, the policy is instead “accept any audience except the MCP resource URI”. |
MCP OAuth (resource server)
Section titled “MCP OAuth (resource server)”The /mcp/v1/common endpoint is an OAuth 2.1 resource server pointed at the
embedded Authorization Server. See
MCP Server.
| Key | Default | Purpose |
|---|---|---|
mcp.oauth_issuer | (empty; defaults to auth.oauth_as.issuer_url when the AS is enabled) | Issuer the MCP endpoint trusts; JWKS is fetched from <issuer>/oauth2/jwks.json. If set explicitly it must equal the AS issuer. Empty with no AS disables the endpoint (rejects all tokens with 401). |
mcp.resource_uri | (empty; auto-derived locally as <issuer>/mcp/v1/common) | Canonical MCP resource identifier and required token audience (RFC 8707). Required when the AS is enabled. |
In local development both fields are auto-derived — leave them empty. In
production set them explicitly (e.g. https://connect.example.com/mcp/v1/common).
Frontend & runtime /config.js
Section titled “Frontend & runtime /config.js”The frontend block holds the SPA base URL plus deploy-time, non-secret values
the backend serves to the SPA at /config.js (as
window.__VIBEXP_ENV__). Each field mirrors a VITE_* value the frontend
would otherwise bake in at build time; empty values fall back to the SPA’s
built-in defaults.
| Key | Default | Purpose |
|---|---|---|
frontend.base_url | http://localhost:5173 | Public URL of the web app (redirects, email links). Also the local-development detection signal: a localhost value enables dev-only paths. In the combined image, set it to your public URL. |
frontend.site_name / frontend.site_legal_name / frontend.site_url | (empty) | Branding shown by the SPA. |
frontend.terms_url / frontend.privacy_url / frontend.support_email | (empty) | Legal/support links. |
frontend.brand_logo_url | (empty) | Logo URL. |
frontend.mcp_endpoint | (empty) | MCP endpoint URL shown in the connect UI. |
frontend.error_type_base_uri | (empty) | RFC 9457 base URI the SPA links error codes to. |
frontend.gtm_id / frontend.ga4_measurement_id | (empty) | Optional analytics. Setting gtm_id is the opt-in: the SPA loads Google Tag Manager only when it is non-empty. There is no separate enable flag, and VibeXP ships no cookie-consent gate of its own, so configure consent inside your own tag container. |
Search ranking
Section titled “Search ranking”When recency_ranking_enabled is false (default) results keep
relevance-only ordering; true blends relevance and freshness by the weights.
Weights must be non-negative and not all zero; the half-life and candidate cap
must be positive (validated at startup).
These are instance defaults: each team inherits them and can override them
on its Search Settings page (Teams → Settings → Search) or via
/api/v1/{team_id}/settings/search, except search.rank_candidate_cap, which
is instance-only and never team-overridable.
| Key | Default | Purpose |
|---|---|---|
search.recency_ranking_enabled | false | Enable the weighted relevance/freshness blend. |
search.rank_weight_relevance | 0.5 | Weight of semantic/keyword relevance. |
search.rank_weight_created | 0.3 | Weight of creation recency. |
search.rank_weight_updated | 0.2 | Weight of update recency. |
search.rank_half_life_days | 90.0 | Freshness decay half-life (max 36500). |
search.rank_candidate_cap | 200 | Re-rank candidate pool size (max 5000). |
Embeddings
Section titled “Embeddings”Embeddings are generated in-process by an async event-bus worker: text is chunked in Go, embedded via the active provider, and stored in pgvector. There is no external AI service and no message broker.
Each per-team embedding provider stores:
| Setting | Default | Purpose |
|---|---|---|
base_url, api_key, provider_type | (required) | Any OpenAI-compatible /v1/embeddings endpoint: OpenAI, Ollama, LocalAI, vLLM, TEI, and similar. API keys are stored encrypted. |
model | (set per provider) | The model_id tag written on every vector row and used as the search filter. |
chunk_size | 1000 | Rune-based chunk size for the in-Go chunker. |
chunk_overlap | 200 | Overlap between chunks (must be smaller than the chunk size). |
concurrency | 1 | Max concurrent embedding requests sent to this provider. |
query_prefix / document_prefix | (empty) | Optional prefixes for asymmetric models (mxbai, BGE, E5) prepended to queries and documents. |
Provider behavior:
- Providers are validated on save and must return 1024-dimension vectors. The width is locked to the pgvector column and is not configurable.
- Changing a provider’s identity (endpoint or model) wipes and re-embeds that team’s vectors. Coverage, one-click reprocess, and clear-all-embeddings actions are available on the settings page and API.
- If no provider is configured, entities are still saved and search falls back to keyword (full-text) mode.
Per-team model providers (bring-your-own OpenAI-compatible LLM endpoints)
are also managed in-app, under /api/v1/{team_id}/settings/model-providers,
not in config.yaml.
| Key | Default | Purpose |
|---|---|---|
email.provider | smtp | smtp, mailgun, postmark, or sendgrid. |
email.from_address | dev@vibexp.local (example file) | Sender address for all providers. Empty falls back to email.smtp.username. |
email.contact_recipient_address | (empty) | Inbox for support notification emails. Falls back to from_address, then smtp.username. |
email.privacy_policy_url | https://example.com/privacy-policy | Privacy-policy link in transactional email footers. |
SMTP (email.provider: smtp). Dev defaults point at Mailpit (UI at
http://localhost:8025).
| Key | Dev default | Purpose |
|---|---|---|
email.smtp.host | localhost | SMTP host. When the host or port is absent the provider falls back to a no-op stub. |
email.smtp.port | 1025 | SMTP port. |
email.smtp.username | dev | SMTP username. |
email.smtp.password | ${SMTP_PASSWORD} | SMTP password (secret). |
Mailgun (email.provider: mailgun).
| Key | Purpose |
|---|---|
email.mailgun.domain | Mailgun sending domain (required). |
email.mailgun.sending_key | ${MAILGUN_SENDING_KEY} — private API key (required). |
email.mailgun.base_url | Region base URL; empty uses the US default. |
Postmark (email.provider: postmark).
| Key | Purpose |
|---|---|
email.postmark.server_token | ${POSTMARK_SERVER_TOKEN} — Server API token (required). |
email.postmark.message_stream | Message stream to send on (default outbound). |
SendGrid (email.provider: sendgrid).
| Key | Purpose |
|---|---|
email.sendgrid.api_key | ${SENDGRID_API_KEY} — API key with the “Mail Send” permission (required). |
The instance provider is also the fallback for teams without their own
provider: each team can configure its own provider in the app (Teams →
Settings → Email Provider; see Email Provider),
and team credentials are stored encrypted with security.encryption_key, and
rotating that key invalidates them. Support mail always uses the instance
provider.
GitHub App
Section titled “GitHub App”There is no github block in config.yaml since v0.9.0. GitHub App
integration is configured per team in the app (open the team → Settings →
GitHub Integration); credentials are stored encrypted in the
github_app_configs table. A leftover top-level github: section in an old
config file fails startup; delete it and re-register the App on each
team. (auth.github, the web-login OAuth client, is unaffected.)
Team-admin setup is in GitHub App; the one-time instance upgrade is Migrating to per-team GitHub Apps.
Attachments (GCS)
Section titled “Attachments (GCS)”| Key | Default | Purpose |
|---|---|---|
storage.attachments_bucket | (empty) | GCS bucket for attachments. Empty disables attachments (upload/download/delete return 503). |
Rate limiting
Section titled “Rate limiting”Per-IP request limits (requests per minute), applied per route group. Each must be ≥ 1.
| Key | Default | Purpose |
|---|---|---|
rate_limit.auth_per_minute | 100 | Per-IP limit on /api/v1/auth/* and the OAuth-AS endpoints. |
rate_limit.api_per_minute | 1000 | Per-IP limit on the authenticated API surface. |
Retention
Section titled “Retention”| Key | Default | Purpose |
|---|---|---|
retention.activity_days | 90 | Activity-log retention window (1–3650 days). |
retention.access_event_days | 90 | Access-event retention window (1–3650 days). |
retention.content_version_limit | 20 | Content-version snapshots kept per resource. 0 disables pruning (keep every version). |
A2A (agent-to-agent)
Section titled “A2A (agent-to-agent)”| Key | Default | Purpose |
|---|---|---|
a2a.default_timeout | 5m | Max time to wait for synchronous agent-to-agent HTTP responses. |
a2a.stream_timeout | 2h | Max lifetime of a streaming (SSE) agent task. Decoupled from the sync timeout so long-running streams are not cut short. |
Scheduler
Section titled “Scheduler”The platform’s own engine for recurring work, meant to remove the need for an external cron as features move onto it. A ticker loop claims each due schedule under its own Postgres advisory lock, which keeps it correct across replicas: two instances ticking at once never double-run the same schedule. Due-ness is computed from the database clock, not the app server’s, so replica clock skew does not matter. A job that panics, fails, or times out is logged and the schedule still advances; on shutdown the loop waits for the job in flight to return before exiting.
Nothing has moved onto it yet, so the externally driven
internal job endpoints (/internal/jobs/*,
retention and digests) still need their external scheduler.
Schedules live in the schedules table (migration 012_schedules) and their
interval has a 1-hour floor, enforced both in code and by a database check
constraint.
| Key | Default | Purpose |
|---|---|---|
scheduler.enabled | true | Turns the loop on. false means nothing is claimed or run. |
scheduler.tick_interval | 1m | How often the loop looks for due schedules. A polling cadence, not a job cadence. |
scheduler.job_timeout | 10m | Bounds a single job handler invocation. |
scheduler.due_limit | 100 | Caps how many due schedules one tick claims. |
Deployment environment detection
Section titled “Deployment environment detection”The deployment block holds environment-detection indicators (used to label
telemetry and pick the deployment environment). Most are auto-populated by the
hosting platform; config.example.yaml passes them through with
${VAR:-} references, so manual setting is rarely needed. Detection order:
otel_environment, then environment/env/deployment_environment, then
cloud indicators (kubernetes_service_host, k_service / k_revision
(Cloud Run), google_cloud_project / gcp_project, aws_region /
aws_default_region), defaulting to production.
Event bus
Section titled “Event bus”Async event processing (embeddings and other post-commit side effects). All optional with production-tuned defaults; values are validated and capped.
| Key | Default | Max | Purpose |
|---|---|---|---|
event_bus.worker_count | 20 | 1000 | Concurrent event workers. |
event_bus.buffer_size | 500 | 10000 | Event queue buffer size. |
event_bus.max_retries | 3 | 10 | Max retry attempts per event. |
event_bus.retry_backoff | 200ms | — | Base retry delay; exponential per attempt. |
event_bus.retry_jitter | true | — | Randomize backoff (±10%) to avoid thundering herd. |
OpenTelemetry
Section titled “OpenTelemetry”| Key | Default | Purpose |
|---|---|---|
otel.endpoint | localhost:4317 | OTLP collector endpoint (gRPC host:port, no scheme). |
otel.export_interval | 60s | Metric export interval. |
otel.trace_sample_ratio | 0.1 | Trace sampling ratio. |
otel.tracing_enabled | false | Enable trace export. |
Internal jobs (Pub/Sub OIDC)
Section titled “Internal jobs (Pub/Sub OIDC)”OIDC auth for internal job endpoints (/internal/jobs/*), e.g. Cloud
Scheduler. Unrelated to embeddings; leave empty for local dev.
| Key | Default | Purpose |
|---|---|---|
gcp.project_id | (empty) | GCP project id, used only for trace/log correlation. |
gcp.pubsub_push_audience | (empty) | Public HTTPS URL the OIDC caller targets; the token’s audience must match it. |
gcp.pubsub_push_service_account_suffix | (empty) | Optional. Restrict accepted tokens to a service-account domain (token email must end with this suffix). |