
<div class="callout callout-attn"><p><strong>Alpha.</strong> VIA is under active development. APIs, operational flows, and host requirements may change between releases.</p></div>

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`:

```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 = [...]`:

```toml
[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:

```bash
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:

```bash
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:

```bash
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

```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](/docs/via/operations/) for status, doctor, history, and key rotation.
