Skip to content

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

  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:

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

KeyDefaultPurpose
server.port8080HTTP listen port.
server.log_levelinfodebug | info | warn | error. Use debug locally.
server.log_formatjsonjson (structured, for aggregators) or text (human-readable).
server.service_versiondevVersion tag attached to metrics/traces. Release builds inject it.
server.release_sha / server.release_datedev / unknownBuild metadata.
server.max_body_size_bytes10485760 (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_uriabout:blankRFC 9457 error type base URI (joined as <base>/<code>).
KeyDefaultRequiredPurpose
database.hostlocalhostYesPostgres host. May be a Unix socket path (Cloud SQL) when it starts with /.
database.port5432YesPostgres port.
database.uservibexp_appYesDatabase user.
database.password${DB_PASSWORD}YesDatabase password (secret — resolved from the environment).
database.namevibexp_ioYesDatabase name.
database.sslmodedisableNoConnection 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.

KeyDefaultRequiredPurpose
security.encryption_key${ENCRYPTION_KEY}YesAES-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}NoShared API key for the common API surface.
security.backoffice_admin_api_key${BACKOFFICE_ADMIN_API_KEY}NoAdmin key for back-office endpoints (/bo/*). Separate from JWTs and regular API keys.
security.outbound_allowed_cidrs[] (env ${OUTBOUND_ALLOWED_CIDRS}, comma-separated)NoNetworks 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.

Selects and configures the web-login identity providers. See Authentication for the full model.

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

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.

KeyPurpose
auth.google.client_id / auth.google.client_secret / auth.google.redirect_uriGoogle OIDC web-login client (reached directly via accounts.google.com discovery).
auth.github.client_id / auth.github.client_secret / auth.github.redirect_uriGitHub 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_uriGeneric 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.

KeyDefaultPurpose
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_ttl15mAccess-token lifetime.
auth.oauth_as.refresh_token_ttl720hRefresh-token lifetime. Must exceed the access-token TTL.
auth.oauth_as.auth_code_ttl10mAuthorization-code lifetime.
auth.oauth_as.key_rotation_interval720hHow often the RS256 signing key rotates (coordinated across replicas).
auth.oauth_as.cleanup_interval1hHow 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.
KeyDefaultPurpose
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.

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

KeyDefaultPurpose
frontend.base_urlhttp://localhost:5173Public 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.

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.

KeyDefaultPurpose
search.recency_ranking_enabledfalseEnable the weighted relevance/freshness blend.
search.rank_weight_relevance0.5Weight of semantic/keyword relevance.
search.rank_weight_created0.3Weight of creation recency.
search.rank_weight_updated0.2Weight of update recency.
search.rank_half_life_days90.0Freshness decay half-life (max 36500).
search.rank_candidate_cap200Re-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:

SettingDefaultPurpose
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_size1000Rune-based chunk size for the in-Go chunker.
chunk_overlap200Overlap between chunks (must be smaller than the chunk size).
concurrency1Max 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.

KeyDefaultPurpose
email.providersmtpsmtp, mailgun, postmark, or sendgrid.
email.from_addressdev@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_urlhttps://example.com/privacy-policyPrivacy-policy link in transactional email footers.

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

KeyDev defaultPurpose
email.smtp.hostlocalhostSMTP host. When the host or port is absent the provider falls back to a no-op stub.
email.smtp.port1025SMTP port.
email.smtp.usernamedevSMTP username.
email.smtp.password${SMTP_PASSWORD}SMTP password (secret).

Mailgun (email.provider: mailgun).

KeyPurpose
email.mailgun.domainMailgun sending domain (required).
email.mailgun.sending_key${MAILGUN_SENDING_KEY} — private API key (required).
email.mailgun.base_urlRegion base URL; empty uses the US default.

Postmark (email.provider: postmark).

KeyPurpose
email.postmark.server_token${POSTMARK_SERVER_TOKEN} — Server API token (required).
email.postmark.message_streamMessage stream to send on (default outbound).

SendGrid (email.provider: sendgrid).

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

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.

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

KeyDefaultPurpose
rate_limit.auth_per_minute100Per-IP limit on /api/v1/auth/* and the OAuth-AS endpoints.
rate_limit.api_per_minute1000Per-IP limit on the authenticated API surface.
KeyDefaultPurpose
retention.activity_days90Activity-log retention window (1–3650 days).
retention.access_event_days90Access-event retention window (1–3650 days).
retention.content_version_limit20Content-version snapshots kept per resource. 0 disables pruning (keep every version).
KeyDefaultPurpose
a2a.default_timeout5mMax time to wait for synchronous agent-to-agent HTTP responses.
a2a.stream_timeout2hMax 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.

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.

KeyDefaultPurpose
scheduler.enabledtrueTurns the loop on. false means nothing is claimed or run.
scheduler.tick_interval1mHow often the loop looks for due schedules. A polling cadence, not a job cadence.
scheduler.job_timeout10mBounds a single job handler invocation.
scheduler.due_limit100Caps 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.

KeyDefaultMaxPurpose
event_bus.worker_count201000Concurrent event workers.
event_bus.buffer_size50010000Event queue buffer size.
event_bus.max_retries310Max retry attempts per event.
event_bus.retry_backoff200msBase retry delay; exponential per attempt.
event_bus.retry_jittertrueRandomize backoff (±10%) to avoid thundering herd.
KeyDefaultPurpose
otel.endpointlocalhost:4317OTLP collector endpoint (gRPC host:port, no scheme).
otel.export_interval60sMetric export interval.
otel.trace_sample_ratio0.1Trace sampling ratio.
otel.tracing_enabledfalseEnable trace export.

OIDC auth for internal job endpoints (/internal/jobs/*), e.g. Cloud Scheduler. Unrelated to embeddings; leave empty for local dev.

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