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 issuespg_notifyon a dedicated channel,app_settings_changed(theSettingsNotifyChannelconstant), so the notification always follows a durable write.- Each replica runs a boot-time
admin.Settings.Watchgoroutine, holding its ownLISTENconnection on that channel. This is started both fromcmd/server/main.go(API server replicas) and fromcmd/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 fromapp_settings. - A bounded fallback reload on a ticker (
DefaultSettingsWatchFallbackIntervalininternal/admin/settings.go) reloads the cache even when a notification is lost or theLISTENconnection 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
- Confirm the write actually succeeded — the
POST/PUTto/api/v1/admin/settingsreturned a2xxresponse. - Check whether the lagging replica can reach Postgres. A
Watchgoroutine that cannot complete a fallback reload logsadmin.Settings.Watch: fallback reload failedin that replica's own log. - Read the value back directly against the lagging replica —
GET /api/v1/admin/settings/{key}routed to that specific replica, or a direct query againstapp_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.