Alpha. Vary is under active development and not ready for production use. Syntax, APIs, performance, and behaviour may change between releases.
Secrets and config
Via has two kinds of runtime settings. Plain config is for values that can appear in command output, deploy logs, or support bundles. Secrets are for values that should never travel through argv, shell history, generated manifests, or diagnostic JSON.
The config server delivers both kinds of values to running apps, but secret access has a narrower path: the app must declare the secret name, the runner must mint a workload token for that instance, and the config server must authorize the request before it reads the stored value.
Tracked components
After install, via status prints one line per component with the unit's state and bound endpoint.
| Component | systemd unit | Endpoint |
|---|---|---|
| control-plane | via-server.service | 127.0.0.1:8080 |
| builder | via-builder.service | (no listener) |
| runner | via-runner.service | (no listener) |
| config-server | via-config.service | http://0.0.0.0:8181 |
| proxy | managed proxy service | loopback admin endpoint |
Config server endpoint
Runtime containers use the config server to fetch declared configuration and
secrets with workload identity. By default it binds on 0.0.0.0:8181 so
runtime containers can reach it through the Docker host-gateway alias. The bind
address comes from the [config_server] section of /etc/via/server.toml:
[config_server]
host = "0.0.0.0"
port = 8181
Workload identity JWT verification is the security boundary. Firewall port 8181 externally on production hosts; it is for host-to-container traffic, not a public API.
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Liveness probe. Returns 200 with body ok\n. No auth. |
| GET | /config/<env>/<name> | Returns the current secret value if the workload token authorizes it. Requires Authorization: Bearer <jwt> with aud = "via-config". |
| POST | /v1/runtime/config/resolve | Returns authorized env values and add-on descriptors for the running release. Requires the same workload audience. |
| POST | /v1/runtime/config/refresh | Refreshes selected env or descriptor keys by app/release/deploy/profile/scope/generation. |
| GET | /v1/apps/<app>/config-server/policy | Operator API for the app runtime-config policy: audience, env scopes, add-on scopes, descriptor policy, and refresh window. |
| PATCH | /v1/apps/<app>/config-server/policy | Updates that policy by appending a new config profile. |
The secret endpoint maps response codes to typed runtime-client errors:
| Status | Body shape | Runtime mapping |
|---|---|---|
200 OK | raw value bytes | config.secret returns the value |
404 Not Found | missing\n | ConfigMissingError |
403 Forbidden | denied undeclared_secret <reason> | ConfigUndeclaredError |
403 Forbidden | denied <code> <reason> | ConfigPermissionDeniedError |
500 Internal Server Error | error <reason> | ConfigServerUnavailableError |
Successful 200 responses set Cache-Control: no-store so intermediaries do not retain the value past the request.
Workload identity flow
When the runner promotes an ARTIFACT_READY deploy to RUNNING, it issues a per-instance signed JWT and bind-mounts it into the container. No secret value or long-lived credential is shared with the container.
| Step | Action |
|---|---|
| 1 | Pre-generate the runtime instance id (rti_inst_<hex>). |
| 2 | Mint a signed JWT under the active identity key with claims aud = "via-config", app, deploy, artifact, instance, secrets, iat, exp, jti. |
| 3 | Write the JWT to a per-instance file under /var/lib/via/runners/identity/<instance-id>/token (mode 0600, dir mode 0700). |
| 4 | Bind-mount that file into the container at /run/vary/identity/token read-only. |
| 5 | Set bootstrap env: VARY_CONFIG_URL and VARY_CONFIG_TOKEN_PATH=/run/vary/identity/token. |
| 6 | Append --add-host=via-config-host:host-gateway so the container resolves the host loopback over the docker bridge. |
| 7 | Audit row: audit_events.action = runtime_identity_issued, target_type = runtime_instance. |
Token bounds:
| Field | Default | Cap | Notes |
|---|---|---|---|
| TTL | 900s | 3600s | Renewals overwrite the on-disk token in place with a fresh jti. |
aud | via-config | - | Build containers receive a different audience (vary-build); the config server rejects them. |
iss | identity issuer | - | Stamped by the active identity signing key. |
Verification at the config server
Every /config/<env>/<name> request runs through a single authorization choke point:
| Check | Result on failure |
|---|---|
| Signature, audience, exp | denied token_invalid |
| App row exists | denied unknown_app |
| Runtime instance exists | denied unknown_instance |
| App id matches token | denied app_mismatch |
| Deploy id matches token | denied deploy_mismatch |
Runtime is running | denied instance_not_running |
Deploy is running | denied deploy_not_active |
Name in secrets claim | denied undeclared_secret |
| Secret value lookup | 200 OK (raw value), 404 (missing), or 500 (error) |
Every outcome (allowed, denied, missing, error) writes one audit_events row with action = config_secret_access, target_id = <secret name>, and flat JSON metadata carrying token id, app id, deploy id, instance id, environment, secret name, and (for denials) the typed code. Plaintext values are never serialized.
Declaring secrets in the app manifest
A runtime container is only allowed to fetch a secret whose name appears in [capabilities] secrets = [...]:
[capabilities]
secrets = ["DATABASE_URL", "STRIPE_KEY"]
Names not declared produce denied undeclared_secret regardless of what is stored. This gate is enforced at issuance (the secrets claim) and at the config server (membership check before the value lookup).
App env and config CLI
Use the HTTP target flow for app runtime configuration:
vary target add prod https://vary.example.com
vary login --target prod --name alice --token-stdin
vary app env list api --target prod
vary app env set api DATABASE_URL --target prod --secret
vary app env rotate api DATABASE_URL --target prod
vary app env history api DATABASE_URL --target prod
vary app env set api LOG_LEVEL --target prod --value info
vary app config patch api --target prod --replicas 2 --memory-mb 512
vary app config diff api --target prod
vary app config redeploy api --target prod
For one secret, prefer the masked prompt shown above. It keeps the value out of
shell history, ps, terminal scrollback, and copied runbooks. In automation,
redirect stdin from an existing secret file or use your CI secret store's
masked-stdin facility:
vary app env set api DATABASE_URL --target prod --secret < /run/secrets/api-database-url
For several values, render a template, fill it in outside your shell history, apply it once, then delete the local copy:
vary app config template api --target prod --workdir . > api.prod.env
chmod 600 api.prod.env
$EDITOR api.prod.env
vary app config apply api --target prod --file api.prod.env
rm api.prod.env
The template uses KEY=value for plain config and secret:KEY=value for
secrets. Treat a filled template like a password file. Do not commit it, paste it
into tickets, or keep it in a shared shell transcript.
Avoid any command that places the secret value directly in the command text. Even if the CLI rejects argv secrets, the shell may have already recorded the line.
Secret env writes read from stdin or a masked TTY prompt. Secret values are not accepted in argv. Default output renders them as <redacted>; --json responses are also redacted. Rotate and history responses show generation, status, timestamps, source, and provider metadata only.
Secret provider metadata is provider-neutral:
| Field | Contract |
|---|---|
provider | Opaque string. Current local storage writes local_encrypted; clients must tolerate unknown future values. |
source | Opaque string. Current local storage writes local; clients must tolerate unknown future values. |
generation | Integer generation served to operators and runtimes for refresh validation. |
metadata | String map for provider-neutral details. Local storage paths, key ids, and key-file locations are never returned. |
Rotation and runtime cache
vary app env rotate <app> <name> --target <target> creates a new generation
for a secret-backed env value. Previous generations remain in the control plane
for audit, but the config server returns the current generation by default.
Host operators can also use via app secret rotate <name> --app <app> for the
local app-secret store. That command runs on the Via host, not through a
workstation target.
Each running app's runtime config client memoizes a successful config.secret(...) response for 30 seconds. The cache is per-process, keyed by the full request URL (base + environment + name), and is bypassed for any non-200 response.
| Setting | Behavior |
|---|---|
| (unset / default) | 30s memoization for 200 responses |
VARY_CONFIG_CACHE_TTL_SECONDS=0 | No memoization; every config.secret(...) hits the wire |
VARY_CONFIG_CACHE_TTL_SECONDS=N>0 | N-second memoization (operator-tuned) |
After a rotation, every running app sees the new value within the cache TTL. For an immediate rollout, restart the affected runtime containers; a fresh process starts with an empty cache. Errors are never cached, so a transient denial or upstream failure does not pin a stale outcome.
Runtime refresh requests include the app, release, deploy, config profile version, key scope, key name, and generation. The server accepts the currently running profile for authorization, then materializes the newest active app profile so env rotation and add-on credential rotation can reach running workloads without changing the release that was originally deployed.
Calling config from Vary
import config
def main() {
let url = config.secret("DATABASE_URL")
let debug = config.bool("DEBUG", False)
print(url)
print(debug)
}
config.secret(name) returns the declared secret as a string or raises one of five typed errors (ConfigMissingError, ConfigUndeclaredError, ConfigPermissionDeniedError, ConfigServerUnavailableError, ConfigBadTypeError). Catch them individually with except ConfigMissingError as e or fall back to the family with except ConfigError as e.
config.bool(name, default) returns a typed public config value with a default. Both call the same wire shape and use the same cache policy.
Avoid these patterns
| Anti-pattern | Why it fails |
|---|---|
Set VARY_DATABASE_URL in vary.toml [runtime] env | The runner refuses any env name under the forbidden prefixes (VARY_IDENTITY_*, VARY_SIGNING_*, etc.). Even if not refused, container env is inspectable via docker inspect. |
| Bake a secret into the image | Vary uses a fixed runtime image and bind-mounts the artifact read-only. Image layers carry no per-app code. |
| Pass a secret on the deploy command line | The CLI never accepts secret values via argv. |
| Read a secret from a build container | Build containers carry aud = "vary-build"; the config server rejects them at the audience check. |
Continue to Operations for status, doctor, history, and key rotation.