Skip to content

Backend Configuration

Edit on GitHub

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).

  1. Path resolution. The config file path is taken from the --config flag, then the VIBEXP_CONFIG_FILE environment variable, then ./config.yaml.
  2. Fail-fast. The file is required: a missing file aborts startup with an error naming the expected path and pointing at config.example.yaml.
  3. Defaults, then file. Code-level defaults are merged first; the YAML file overrides any of them, and the result is unmarshalled into a nested Config struct (the koanf tags name each section/key).
  4. Interpolation. ${VAR} references in the file’s string values are resolved against the process environment (see the grammar below).
  5. 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 leftover embedding: 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 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.

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.

Terminal window
make backend-generate-config-schema # regenerate via go run ./cmd/gen-config-schema
make backend-config-schema-check # regenerate, then fail if it differs from the committed file

CI runs backend-config-schema-check, so regenerate and commit the schema whenever you change the Config struct.

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>).
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.

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.

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.

Session, dev-login, recovery and root-admin settings. The web-login identity providers and the access allowlist are stored in the database since v0.15.0 and edited in the admin area; the keys marked deprecated below are only imported at startup while nothing is stored. See Authentication for the full model.

Key Default Purpose
auth.providers [] Deprecated since v0.15.0 (imported at startup while no provider is stored, otherwise ignored). 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) Deprecated since v0.15.0, like auth.providers. 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 [] Deprecated since v0.15.0 (imported at startup while no allowlist is stored, otherwise ignored). Allowed email domains (matched exactly against the part after the last @).
auth.access_allowlist.emails [] Deprecated since v0.15.0, like domains. 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.recovery_mode false Break-glass after an authentication lockout: forces setup mode on and logs a new one-time SETUP URL on every start, while the stored providers keep working. Wired to AUTH_SETTINGS_RECOVERY_MODE in the Docker config.
auth.instance_admins [] Emails designated as root instance administrators; further admins are granted in the admin area. Drives is_instance_admin and the /api/v1/admin portal; matched case-insensitively and validated as emails at startup.

Deprecated since v0.15.0. These blocks are read only by the startup import: an enabled, complete provider is copied into the database while no provider is stored, and the block is ignored from then on. The redirect_uri values are not used at all: every provider redirects to <frontend.base_url>/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/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_url is set (including the local-dev auto-enable) and auth.api_oauth.issuer is left empty, VibeXP auto-derives both: issuer is set to the AS issuer and audiences is pinned to [mcp.resource_uri] (require that audience). Native-CLI login works on /api/v1 with no manual config.
  • External IdP (manual override). Set auth.api_oauth.issuer explicitly to point /api/v1 at 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”.

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).

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 of those fields mirrors a VITE_* value the frontend would otherwise bake in at build time; empty values fall back to the SPA’s built-in defaults.

Company branding is not here. Since v0.15.0 the name, logo and support and legal links are the instance’s site info, stored in the database and edited in the admin area: see Site Info (Branding). The seven branding keys this block used to hold are ignored, as listed in Upgrading.

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.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.

The keys below still load in v0.15.0, and only feed that import. Weights must be non-negative and not all zero; the half-life and candidate cap must be positive (still validated at startup).

The values they import 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 the 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 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 when that is itself an email address; with neither, nothing is imported.
email.contact_recipient_address (empty) Inbox for support notification emails. Empty uses the from address.

The privacy policy URL key this section used to hold is gone: v0.15.0 ignores it, logs a warning at startup while it is still set, and does not import it. The privacy policy link is part of the instance’s site info now, set in the admin area.

SMTP (email.provider: smtp). Dev defaults point at Mailpit (UI at http://localhost:8025).

Key Dev default Purpose
email.smtp.host localhost SMTP host. Imported only when host and port are both set and either a password is set or the pair differs from the built-in default smtp.gmail.com:587.
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). Instance and team credentials are both stored encrypted with security.encryption_key, and rotating that key invalidates them. Support mail always uses the instance provider. When neither a team provider nor an instance provider is configured, the mail is discarded.

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.

Key Default Purpose
storage.attachments_bucket (empty) GCS bucket for attachments. Empty disables attachments (upload/download/delete return 503).

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.
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).
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.

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.

Today it runs one job type, freshness-rule evaluation (freshness_evaluate): every team with freshness rules gets a schedule that flags resources nobody has read for long enough. Retention and digests have not moved onto it, so the externally driven internal job endpoints (/internal/jobs/*) 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 Env var Purpose
scheduler.enabled true SCHEDULER_ENABLED Turns the loop on. false means nothing is claimed or run.
scheduler.tick_interval 1m SCHEDULER_TICK_INTERVAL How often the loop looks for due schedules. A polling cadence, not a job cadence.
scheduler.job_timeout 10m SCHEDULER_JOB_TIMEOUT Bounds a single job handler invocation.
scheduler.due_limit 100 SCHEDULER_DUE_LIMIT Caps how many due schedules one tick claims.

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.

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.
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.

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).