auth (data plane) and central (control plane) each read one HOCON file at startup and a handful of environment variables. This page lists both, block by block, and says which blocks a production deployment needs. edge has its own block list; see Edge configuration.
You normally do not write these files by hand. versola configure (and the Helm chart) generate them with versola-tools for the release you deploy, with secrets as ${VAR} placeholders. They are regenerated on every configure, so on the vps target a hand edit is lost; there is no supported way yet to change an individual setting there. Treat this page as a reference for what the generated files contain.
How configuration is loaded
- The service reads the HOCON file at the path in
CONFIG_PATH(container default/app/config/env.conf; the image passes it to the JVM as-Denv.path). As an alternative, the whole configuration can be passed inline in theENV_CONFIGenvironment variable. - Keys are written in kebab-case (
maximum-pool-size). Durations are HOCON durations ("60 seconds",5 minutes). - Standard HOCON substitution applies, so
${VAR}and${?VAR}take values from environment variables. The generated files use this for secrets:versola configureresolves each secret from OpenBao into a<service>.secrets.envfile that Compose passes to the container as environment, and the configuration file only holds the placeholder. On Kubernetes the same keys come from a Secret (Kubernetes). - A syntax error stops the service at startup. HOCON cannot read UTF-16: keep hand-edited files UTF-8.
envmust be set. The valueprodmarks production; any other value is treated as a test environment. Production is stricter in places noted below (for example, Postgres TLS).
Environment variables
| Variable | Default | Meaning |
|---|---|---|
PORT | 8080 | Application port. |
DPORT | 8081 | Diagnostics port: /metrics, /liveness, /readiness. See Observability. |
APORT | 8082 | Additional internal application port. Used by auth for Account Settings; not a public surface. |
MPORT | 8083 | auth mutual-TLS listener. Serves only if the mutual-tls block exists. |
BIND_HOST | 0.0.0.0 | Address the listeners bind to. The vps Compose file sets 127.0.0.1. |
CONFIG_PATH | /app/config/env.conf | Path of the HOCON file (images). |
ENV_CONFIG | none | Inline HOCON; used instead of the file when set. |
RUN_MIGRATIONS | false | true: apply Flyway migrations at startup. false or unset: validate the schema and refuse to start on an out-of-date one. Any value other than exactly true/false stops the service. Deployments use versola migrate instead. |
JAVA_OPTS | see JVM defaults | JVM flags. Setting it replaces the defaults entirely. |
WARMUP_ENABLED | true | Run the warm-up before the service reports ready. Exactly true or false. |
WARMUP_BUDGET_SECONDS | 20 | Upper bound for the warm-up; after it readiness proceeds anyway. |
HTTP_CLIENT_POOL_KIND | fixed | Pool of the outbound HTTP client shared by all three services: fixed or dynamic. See Outbound HTTP client pool. An invalid value falls back to the default. |
HTTP_CLIENT_POOL_SIZE | 128 | Connections per target. For dynamic, the maximum. |
HTTP_CLIENT_POOL_MIN | 0 | dynamic only: connections kept open when idle. |
HTTP_CLIENT_POOL_TTL | 30s | dynamic only: how long the pool must be unused before it shrinks to the minimum, for example 30s or 2m. |
ARGON2_MAX_CONCURRENCY | none | auth only: overrides argon2.max-concurrent in the generated file. |
POSTGRES_POOL_METRICS_INTERVAL | none | Overrides postgres.pool-metrics-interval in the generated file. |
The generated configuration also reads a few variables the chart or Compose file sets for you (for example the certificate issuer on Kubernetes). Anything else, including secrets, lives in the HOCON file and the secret store.
Blocks shared by every service
Top level
| Key | Required | Meaning |
|---|---|---|
env | yes | Environment name; prod enables production checks. |
configuration-cache-refresh-interval | auth, central | How often a cache of central’s configuration is refreshed. Generated: 5 minutes for vps and Kubernetes, 1 minute locally. |
otel-exporter | no | OTLP gRPC endpoint for traces, for example http://otel-collector:4317. Absent: tracing is off. The generated files carry it commented out. |
postgres
Every key except notifications-url, ssl-root-cert and pool-metrics-interval is required and has no default, so that the connection budget of all instances can be planned against Postgres max_connections. The values below are what versola-tools generates.
| Key | Generated | Meaning |
|---|---|---|
url | per service | JDBC URL. Standard topology: a database per service, so each service has its own URL, user and password. The vps and local targets generate the simplest one, a single database auth with a schema per service: ...?currentSchema=auth (or central, edge). Never point two services at the same schema. |
user | role name | Database role. |
password | ${POSTGRES_PASSWORD} | Password. |
maximum-pool-size | auth 10, central 15 | HikariCP pool maximum. |
minimum-idle | equal to maximum | Idle connections kept; must not exceed maximum-pool-size. |
connection-timeout | 30 seconds | Wait for a pooled connection before failing. |
max-lifetime | 30 minutes | Connection lifetime; keep it below any server or proxy timeout. |
leak-detection-threshold | 60 seconds | A connection held longer than this logs a warning. 0 seconds disables. |
notifications-url | absent | Direct (non-pooled) JDBC URL used only for the LISTEN connection. Falls back to url. |
ssl-root-cert | absent | Path to a CA bundle that verifies the database certificate. Absent: the JVM trust store. |
pool-metrics-interval | absent | Publish pool gauges (db_client_connection_*) at this interval, for example 10 seconds. Absent: none are published. |
Postgres TLS
Every connection to Postgres is opened with sslmode=verify-full unless the URL sets its own sslmode: the certificate must chain to a trusted CA and match the host name. This applies to the pool and to the notifications-url connection.
- To trust a private CA, mount its bundle into the container and set
ssl-root-cert; on thevpstarget pass--postgres-ca <file>toversola configureand the CLI does both. Asslrootcertalready in the URL wins. - A URL that does not verify (
sslmode=disable,allow,preferorrequire, orssl=false) logs a warning at startup. Withenv = "prod"the service refuses to start. - Hosts
localhost,127.0.0.1and[::1]are exempt from both.versola-toolswritessslmode=disablefor the bundled local Postgres and for avpswhose--postgres-hostis loopback; a remotevpshost keeps the verified default. - An
sslfactoryin the URL that may skip validation is reported the same way.
Behind a connection pooler
central and edge hold a connection parked on LISTEN: that is how a configuration change reaches every replica and how a revoked token stops being accepted before it expires. A pooler in transaction mode cannot carry LISTEN. When url points at a pooler, set notifications-url to a direct Postgres URL:
postgres {
url = "jdbc:postgresql://pgbouncer:6432/auth?currentSchema=edge"
notifications-url = "jdbc:postgresql://postgres:5432/auth?currentSchema=edge"
}
A service checks at startup that it can receive its own notification and refuses to start, naming this setting, if it cannot.
auth
Required in production: env, configuration-cache-refresh-interval, security, jwt, central, postgres. Optional: bootstrap, par, dpop, argon2, mutual-tls, otp-provider, smtp, cleanup. The generated file includes bootstrap, par, argon2, mutual-tls and cleanup.
security
Eleven secrets, all required. Each is a base64url value; the generated placeholders and the decoded length are:
| Key | Secret variable | Bytes |
|---|---|---|
access-tokens-secret | ACCESS_TOKENS_SECRET | 32 |
client-secrets-secret | CLIENT_SECRETS_SECRET | 16 |
refresh-tokens-secret | REFRESH_TOKENS_SECRET | 32 |
auth-codes-secret | AUTH_CODES_SECRET | 32 |
sessions-secret | SESSIONS_SECRET | 32 |
passwords-secret | PASSWORDS_SECRET | 16 |
conversation-cookie-secret | CONVERSATION_COOKIE_SECRET | 32 |
session-cookie-secret | SESSION_COOKIE_SECRET | 32 |
user-agent-cookie-secret | USER_AGENT_COOKIE_SECRET | 32 |
par-requests-secret | PAR_REQUESTS_SECRET | 32 |
dpop-nonces-secret | DPOP_NONCES_SECRET | 32 |
Some of these protect stored data and must never be regenerated; see Secrets.
jwt
| Key | Meaning |
|---|---|
issuer | The issuer string and public origin. Generated from --auth-url. Tokens, discovery metadata and the DPoP htu check use it, never the Host header. |
private-key | RSA private key (PKCS#8 DER, standard base64), ${JWT_PRIVATE_KEY}. |
central
| Key | Meaning |
|---|---|
url | Address of central. auth syncs its configuration from it and blocks (up to two minutes) until it is reachable. |
secret-key | Key shared by auth and central, ${CENTRAL_SECRET_KEY}. |
par
Optional. Pushed authorization requests (RFC 9126).
| Key | Default | Meaning |
|---|---|---|
request-uri-ttl | 60 seconds | Lifetime of a request_uri. |
max-request-size | 8192 | Maximum size of a pushed request, in bytes. |
dpop
Optional. Defaults apply when the block is absent.
| Key | Default | Meaning |
|---|---|---|
iat-leeway | 60 seconds | Allowed distance between a proof’s iat and the current time, in either direction. Also the window a proof is remembered for replay detection. |
nonce-ttl | 300 seconds | How long a server-issued DPoP-Nonce is accepted. |
Whether a nonce is required is a per-tenant setting in the admin console, not configuration. See Sender-constrained tokens.
mutual-tls
Optional. Starts a second listener on MPORT that requires a client certificate and serves /token, /introspect, /revoke, /par and /userinfo, and makes discovery advertise mtls_endpoint_aliases (RFC 8705 §5). Without the block, a client certificate reaches auth only as a header forwarded by a tenant’s proxy.
| Key | Required | Meaning |
|---|---|---|
certificate | yes | PEM path of the certificate the listener presents. |
private-key | yes | PEM path of its key, unencrypted PKCS#8. |
trusted-certificates | yes | PEM path of the CA certificates that client chains are validated against. There is deliberately no default: falling back to the JDK trust store would let every public CA vouch for clients. |
external-url | yes | Address clients are told to use for this listener (the alias in discovery). May differ from where it is bound. |
The vps and Kubernetes targets generate this block and the certificates behind it (a step-ca based mtls-init service, or cert-manager). See Edge client certificates.
argon2
Optional. Argon2id password hashing.
| Key | Default | Meaning |
|---|---|---|
max-concurrent | 12 | Maximum concurrent password hashes. Each holds about 19 MiB of heap while it runs, so the worst case is roughly max-concurrent x 19 MiB (about 228 MiB at 12), sized for the default 512 MiB heap. Raise it only together with the container’s memory. |
otp-provider
Optional, commented out in the generated file. An HTTP endpoint that delivers OTP codes (SMS, for example).
| Key | Meaning |
|---|---|
method | HTTP method, for example POST. |
url | Provider endpoint. |
username, password | Optional basic credentials. |
body | Map of request body fields; values may use the {{phone}} and {{message}} placeholders. |
smtp
Optional, commented out in the generated file. Used for e-mail OTP and password delivery.
| Key | Meaning |
|---|---|
host, port | SMTP server, port is typically 587. |
username, password | Credentials. |
from | Sender address. |
subject | Subject line of the message. |
start-tls | true to upgrade the connection with STARTTLS. |
Without SMTP or an OTP provider, a production deployment has no channel to deliver a temporary password; in prod a password can only be delivered, not shown on screen.
bootstrap
Optional. On a first start, creates the admin user.
| Key | Meaning |
|---|---|
login | Admin login, admin by default. |
password | Temporary password, ${ADMIN_BOOTSTRAP_PASSWORD} on vps and Kubernetes. Valid for 24 hours; the first login forces a new one. Restarting auth after it expires sets a fresh one. |
admin-user-id | UUID of the admin user. Must match central’s bootstrap.admin-user-id. |
cleanup
Periodic deletion of expired rows. The generated file lists these tables with their batch size and interval:
| Table | batch-size | interval | key-column |
|---|---|---|---|
auth_conversations | 1000 | 5 minutes | none |
authorization_codes | 1000 | 5 minutes | code |
pushed_authorization_requests | 1000 | 5 minutes | request_uri |
refresh_tokens | 1000 | 10 minutes | none |
sso_sessions | 1000 | 10 minutes | none |
challenge_throttle | 1000 | 5 minutes | ctid |
user_passwords | 1000 | 12 hours | none |
user_agents | 1000 | 10 minutes | none |
A failed batch no longer stops cleanup of that table until the next restart (fixed in 0.7.0). The service publishes cleanup_* metrics so you can see whether removal keeps up; see Observability.
| Key | Meaning |
|---|---|
max-threads | Cleanup worker threads (2). |
tables[].table-name | Table to clean. |
tables[].batch-size | Rows per batch (1000). |
tables[].interval | How often it runs. |
tables[].key-column | Optional column used to select batches (code, request_uri or ctid for some tables). |
central
Required in production: env, configuration-cache-refresh-interval, secret-key, client-secrets-secret, auth, user-outbox, postgres. Optional: bootstrap, client-certificates, client-certificate-authority.
| Key | Meaning |
|---|---|
secret-key | Key shared with auth, ${CENTRAL_SECRET_KEY}. |
client-secrets-secret | AES key central encrypts client secrets, signing keys, edge keys and resource secrets with, ${CLIENT_SECRETS_SECRET}. Identical to auth’s. Never regenerate it. |
auth.url | Internal address of auth, used for its admin calls. Defaults to the public URL in the generated file. |
user-outbox | How user changes are propagated to auth: poll-interval (1 second), batch-size (32), lease (1 minute), max-backoff (5 minutes), max-attempts (5). After max-attempts failures an event moves to central.user_outbox_dead and is not retried. |
bootstrap
Optional in the code; the generated file always includes it. It seeds central’s database once, on the first start against an empty database. Changing it later has no effect: edit the objects in the admin console instead.
| Key | Required | Meaning |
|---|---|---|
login | yes | Admin login. |
admin-user-id | yes | UUID of the admin user; same as auth. |
redirect-uris | yes | Redirect URIs of the central-admin client. The generated list holds <auth-url>/central/admin/ and the edge’s /complete URL. |
edges | no | Edges to register: [{ id, public-key-jwk }]. |
jwks | no | Public JWK set matching auth’s jwt.private-key, ${JWKS_JSON}. |
metadata | no | Seed of the authorization server metadata document (endpoints, supported algorithms). auth derives some fields when it serves the document; request_uri_parameter_supported is always true (since 0.7.0). |
presets | no | Authorization presets, for the admin console login: id, description, redirect-uri, post-login-redirect-uri, post-logout-redirect-uri, cookie-domain, cookie-path. |
central-url | no | Address of central. |
resource-secret | no | Secret of the central resource, ${CENTRAL_RESOURCE_SECRET}. Generated when absent. |
front-channel-logout-uri | no | Publicly reachable front-channel logout endpoint, typically <auth-url>/logout/frontchannel. |
passkey | yes | rp-id and origins[] for passkeys. |
auth-additional-url | yes | Internal address of auth’s APORT listener. |
auth-resource-secret | yes | Shared secret with that listener, ${ACCOUNT_RESOURCE_SECRET}. Not auto-generated by central. |
utility-client | no | The utils client: client-id, and either public-key-jwk (private_key_jwt with DPoP) or secret. One is required; if both are given the key wins. Seeded once. See Secrets. |
central-admin-mtls | no | certificate-header and certificate-encoding (urlEncodedPem or base64Der) say where a TLS terminator forwards the certificate edge presents as central-admin (RFC 8705 tls_client_auth). certificate (one PEM with the certificate and its PKCS#8 key) is optional: without it central issues the certificate itself through client-certificates (or client-certificate-authority) and refuses to boot when no CA is configured. The vps and local targets generate the block with the header ssl-client-cert and no certificate. Without the whole block central-admin is seeded on a client_secret, which the default FAPI 2.0 tenant does not admit, and bootstrap logs a warning. |
client-certificates
Optional. How central issues and renews certificates for edge-fronted clients. central holds no CA key in this mode. Exactly one backend is configured; the block is read as absent unless a backend is named.
| Key | Default | Meaning |
|---|---|---|
validity | 14 days | Lifetime requested for each certificate. |
renew-before | 4 days | A certificate with less than this left is renewed. |
check-interval | 1 hour | How often stored certificates are examined. |
step-ca | absent | step-ca backend: url, root-certificate (PEM path), provisioner (JWK provisioner name), provisioner-key (JWK file path). Generated for vps and local. |
cert-manager | absent | cert-manager backend: issuer-name, issuer-kind (ClusterIssuer), issuer-group (cert-manager.io), namespace (the pod’s own), api-url, token-path, ca-path, namespace-path. Generated for Kubernetes. |
client-certificate-authority
Optional, local development only: central holds the CA key itself. certificate and private-key are PEM paths (key unencrypted PKCS#8); validity-days optionally caps the lifetime. Without it, a registration that asks central to issue a certificate is refused. Prefer client-certificates anywhere else.
Startup order
auth and edge sync their configuration from central while they start. If central is not listening yet, they wait up to two minutes. The wait recovers by itself only with HTTP_CLIENT_POOL_KIND=dynamic. With the default fixed pool the first request opens all of the pool’s connections, central refuses them, and the pool keeps a failed connection per slot (128 by default), so each retry consumes one and the wait cannot succeed even after central is up. The service exits with code 1 after the two minutes and relies on the restart policy (restart: unless-stopped in the Compose bundles, restartPolicy: Always in Kubernetes); the restarted process builds a fresh pool and starts normally. Expect one restart on a cold install where central is not first. versola up starts central first, so it avoids this. While waiting, /liveness answers 200 and /readiness answers 503. A central that answers and rejects the request (wrong URL, wrong key) fails startup within seconds.
Outbound HTTP client pool
auth, central and edge share one outbound HTTP client: calls between the services and edge’s calls to the backends it proxies. Its pool is kept per target (scheme, host and port), so each backend gets its own pool of the configured size. The resolved pool is logged once at startup (outbound HTTP client pool: ...).
| Variable | Default | Meaning |
|---|---|---|
HTTP_CLIENT_POOL_KIND | fixed | fixed: exactly SIZE connections per target, all opened when the target is first used and kept. dynamic: between MIN and SIZE, more on demand. |
HTTP_CLIENT_POOL_SIZE | 128 | Connections per target; for dynamic the maximum. |
HTTP_CLIENT_POOL_MIN | 0 | dynamic only; not above the maximum. |
HTTP_CLIENT_POOL_TTL | 30s | dynamic only: the pool shrinks back to MIN only after it has seen no checkout or return for this long. It is a pool-wide idle time, so under steady traffic the extra connections stay open. |
An HTTP/1.1 connection serves one request at a time, so a pool caps a pod’s throughput towards one backend at roughly size / backend latency. Neither kind fails when exhausted: a request waits for a free connection with no timeout of its own, so a pool that is too small shows up as latency, never as errors. http_client_active_requests counts outbound requests in flight, queued ones included, and rises first. Size the pool for peak requests per second per pod times the backend’s p99 latency, with headroom. A fixed pool of 128 opens 128 connections to every backend a pod touches; with many backends dynamic with a low MIN uses fewer sockets, at the cost of a connection set-up when a burst arrives.
Login state tables are UNLOGGED
This is one of Versola’s deliberate trade-offs; see Compromises for the full list of UNLOGGED tables, what a crash costs and how to turn logging back on.
Since 0.7.0, the auth_conversations, authorization_codes and pushed_authorization_requests tables are UNLOGGED: they are not written to the write-ahead log. They hold only minutes-old login state, and logging them made the database wait on WAL commits. The trade-off:
- A clean Postgres restart keeps their rows.
- A Postgres crash (kill -9, host failure, power loss) empties them. A standby never receives them, so after a failover they are empty, and a restore from a backup brings them back empty.
- What is lost is in-flight state only: users in the middle of a multi-step sign-in start over, and an authorization code or pushed request that was not yet used is rejected, so the client retries. Sessions, tokens, consents and users are unaffected.
- The migration runs
ALTER TABLE ... SET UNLOGGED, which takes an exclusive lock and rewrites each table. The tables are small, but runversola migrateat low traffic: logins on a still-running older version wait while it runs.
Refresh token lifetime
The lifetime of a refresh token is fixed when it is issued and no longer slides with use (since 0.7.0). It is not a configuration-file setting: it is the refreshTokenTtl of each client, in seconds, set when the client is registered through the Central API or the admin console. When omitted, the default is 15552000 (180 days); releases before 0.7.0 used 90 days and extended it on every use.
Security headers
All application listeners of auth, central and edge (main, additional, mutual-TLS; not the diagnostics port) add these response headers unless the handler already set them:
| Header | Value | When |
|---|---|---|
X-Content-Type-Options | nosniff | always |
Referrer-Policy | no-referrer | always |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() | always |
Content-Security-Policy | frame-ancestors 'none'; base-uri 'none'; object-src 'none' | HTML responses |
X-Frame-Options | DENY | HTML responses |
Strict-Transport-Security | max-age=31536000; includeSubDomains | requests that arrived over https: the URL scheme, X-Forwarded-Proto: https, or the mutual-TLS listener |
There is no default-src Content-Security-Policy or form-action: they would break tenant logos and fonts and redirects to a client’s redirect_uri. Your own TLS-terminating proxy must forward X-Forwarded-Proto for HSTS to be emitted, and includeSubDomains assumes every host under the domain is https-only.
JVM defaults
The auth, central and edge images set JAVA_OPTS to a fixed-size profile on Temurin 25, so memory use does not depend on how much the host has:
-XX:+UseG1GC -Xms512m -Xmx512m -XX:MaxDirectMemorySize=64m -XX:MaxMetaspaceSize=128m
-XX:ReservedCodeCacheSize=64m -Xss512k -XX:NativeMemoryTracking=summary
-XX:+UseCompactObjectHeaders -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/diagnostics/heap.hprof -XX:+ExitOnOutOfMemoryError
-Xlog:gc*:file=/diagnostics/gc.log:time,uptime,level,tags:filecount=5,filesize=20M
--enable-native-access=ALL-UNNAMED
edge sets MaxDirectMemorySize=96m; auth and central use 64m. The heap is 512 MiB, fixed (-Xms equals -Xmx). Budget about 850 MiB of resident memory per service, which is what the chart’s 1 GiB memory limit is sized from. On an OutOfMemoryError the JVM writes one heap dump (overwritten on each crash), exits so the supervisor restarts it, and leaves a rotated GC log in /diagnostics; both deployments mount a volume there. See Observability.
Setting JAVA_OPTS replaces the whole string, so repeat any flag you still want. The older -XX:MaxRAMPercentage=75.0 default is gone: on the vps target nothing limits container memory, so a percentage would be taken from the whole server for each of three JVMs.
Next steps
- Secrets: what each secret protects and how to rotate it.
- Observability: health endpoints, metrics, logs and diagnostics.
- Edge configuration: the same reference for
edge.