Vetrix Docs

Runtime Configuration

Vetrix stores instance configuration in the app_settings table as typed key-value pairs. Settings take effect immediately — no restart required. All endpoints require an admin JWT.

Listing all settings

GET /api/v1/admin/settings
Authorization: Bearer <admin-jwt>

Response:

{
  "auth.max_session_lifetime": "720h",
  "auth.require_2fa": "false",
  "ci.default_job_timeout": "1h",
  "ci.max_concurrent_jobs": "10",
  "maintenance.enabled": "false",
  "registry.enabled": "true",
  "repo.max_size_mb": "1024",
  "security.scan_on_push": "false",
  "signup.enabled": "true",
  "signup.require_confirmation": "false",
  "smtp.host": "",
  "smtp.port": "587"
}

Getting a single setting

GET /api/v1/admin/settings/{key}

Setting a value

PUT /api/v1/admin/settings/{key}
Content-Type: application/json

{ "value": "2048" }

Batch update

All keys are validated before any are written. If any key is invalid the entire batch is rejected.

POST /api/v1/admin/settings
Content-Type: application/json

{
  "repo.max_size_mb": "2048",
  "ci.max_concurrent_jobs": "20"
}

Available settings

Key Type Default Description
signup.enabled bool true Allow new user registration
signup.require_confirmation bool false Require email confirmation on sign-up
auth.require_2fa bool false Enforce TOTP 2FA for all users
auth.max_session_lifetime duration 720h Maximum JWT session lifetime
repo.max_size_mb int 1024 Maximum repository size in MB
ci.max_concurrent_jobs int 10 Maximum concurrently running CI jobs
ci.default_job_timeout duration 1h Default job timeout
registry.enabled bool true Enable the package/container registry
smtp.host string `` SMTP relay hostname for outbound email
smtp.port int (port) 587 SMTP relay port
security.scan_on_push bool false Auto-trigger security scan on every push
maintenance.enabled bool false Enable maintenance mode (blocks non-admin traffic)

Maintenance mode

When maintenance.enabled is true, all non-admin requests receive:

HTTP/1.1 503 Service Unavailable
{
  "error": "service unavailable",
  "message": "Vetrix is currently in maintenance mode. Please try again later."
}

Admin-flagged JWTs and paths under /api/v1/admin/ are always allowed through so that the admin can disable maintenance mode remotely.

To enable:

PUT /api/v1/admin/settings/maintenance.enabled
{ "value": "true" }

To disable:

PUT /api/v1/admin/settings/maintenance.enabled
{ "value": "false" }

Cross-replica propagation

The settings store is a per-process in-memory cache backed by the app_settings table. In a load-balanced deployment a write lands on exactly one replica; every other replica converges on the new value without a restart through Postgres LISTEN/NOTIFY, and this applies to every key in the store, not only the ones listed above:

  • admin.Settings.Set (internal/admin/settings.go) persists the row first, then issues pg_notify on a dedicated channel, app_settings_changed (the SettingsNotifyChannel constant), so the notification always follows a durable write.
  • Each replica runs a boot-time admin.Settings.Watch goroutine, holding its own LISTEN connection on that channel. This is started both from cmd/server/main.go (API server replicas) and from cmd/worker/main.go (worker processes) — a stale-replica triage that checks only server logs can miss a lagging worker. Every notification triggers a full reload of that replica's cache from app_settings.
  • A bounded fallback reload on a ticker (DefaultSettingsWatchFallbackInterval in internal/admin/settings.go) reloads the cache even when a notification is lost or the LISTEN connection drops and re-establishes across a write. Between the notification path and the fallback ticker, cluster-wide convergence on a successful write is expected within about 5 seconds, with no restart required.

Triage: a replica is serving a stale setting

  1. Confirm the write actually succeeded — the POST/PUT to /api/v1/admin/settings returned a 2xx response.
  2. Check whether the lagging replica can reach Postgres. A Watch goroutine that cannot complete a fallback reload logs admin.Settings.Watch: fallback reload failed in that replica's own log.
  3. Read the value back directly against the lagging replica — GET /api/v1/admin/settings/{key} routed to that specific replica, or a direct query against app_settings — to confirm what it currently holds.

Rate limiting

Vetrix rate-limits /login, /register, and /forgot-password using Redis-backed sliding-window counters. The Redis endpoint is supplied via the REDIS_URL environment variable (not in app.toml).

export REDIS_URL="redis://redis:6379/0"

Strongly recommended. When REDIS_URL is unset, ratelimit.NewLimiter returns a no-op limiter that never blocks — brute-force control is effectively off. The server emits this WARN line at startup:

vetrix: rate limiting DISABLED — REDIS_URL is unset. Login, register, and forgot-password endpoints have no throttling.

A parseable URL whose target is unreachable emits:

vetrix: rate limiting degraded — startup Redis ping failed. ...

Both modes keep the server running (fail-open) so a Redis outage never locks users out. Runtime checkCounter failures bump the Prometheus-scrapeable ratelimit_runtime_failures_total counter (via the package-level RuntimeFailures() accessor) and emit at most one WARN per minute to error.log.

Check at runtime from a shell inside the vetrix container:

docker exec mydev_vetrix printenv REDIS_URL
docker exec mydev_redis redis-cli --scan --pattern 'rl:*'

If REDIS_URL is empty inside the container but set in .env, the container was created before .env was edited. Recreate it:

docker compose up -d --force-recreate vetrix

Trusted-proxy allow-list (auth.trusted_proxies)

The auth rate limiter keys per-IP buckets on the request's source IP. Without operator configuration Vetrix ignores X-Real-IP / X-Forwarded-For everywhere — even when sent by a reverse proxy you control — because there is no way to verify whether the immediate TCP peer is allowed to set those headers.

That is the safe default for direct-exposure deployments (an attacker with a TCP socket cannot spoof your rate-limit bucket), but it collapses every per-IP bucket to one container/socket IP when Vetrix sits behind a proxy chain that does forward client IPs. The symptom: ~20 successful logins from any clients sharing the proxy hop saturates the per-IP login bucket and a fresh user gets 429 Too Many Requests on their first attempt.

Configure auth.trusted_proxies with the CIDR of your reverse proxy / load balancer / docker-compose bridge network to opt into per-real-IP bucketing for that hop:

[auth]
# nginx / load balancer behind a private LAN
trusted_proxies = ["10.0.0.0/8"]

# docker-compose dev stack — bridge network defaults to a /16 in 172.x
trusted_proxies = ["172.18.16.0/24"]

# single dedicated proxy host (auto-promoted to /32)
trusted_proxies = ["192.0.2.7"]

You can also supply this via the AUTH_TRUSTED_PROXIES environment variable (comma-separated CIDRs); the env var wins when both are set, which is convenient for docker-compose stacks that should not check the bridge CIDR into source.

When the immediate TCP peer matches one of the trusted entries, Vetrix walks X-Forwarded-For right-to-left, strips trailing trusted hops, and uses the leftmost untrusted IP — i.e. the original client. When the peer is NOT trusted, the headers are ignored and the raw socket address wins, so a client that direct-attaches to Vetrix's port cannot spoof another user's bucket.

The boot log confirms the effective configuration:

auth: trusted_proxies not configured — X-Forwarded-For headers will be ignored
auth: trusted_proxies configured cidrs=[172.18.16.0/24]

If production traffic carries X-Forwarded-For from peers outside the allow-list, Vetrix logs:

auth: ignoring X-Forwarded-For from untrusted peer peer=203.0.113.99

This is the canary that the proxy CIDR is misconfigured (or a real spoofing attempt). The 429 response body's scope field ("ip" vs "username") tells you which bucket exhausted, so you can diagnose dev-stack 429 storms (= IP bucket shared across the proxy chain) without inspecting Redis.