Skip to main content

Secrets

Secrets are not a standalone resource. A service declares the runtime values it needs in its environment.yaml sidecar — an ENV_VAR_NAME: <source> map — and inforge injects each as an environment variable when the service starts. This page covers the source DSL, the git-encrypted store behind vault:, and how a resolved value reaches a running service. Secret values are never baked into an artifact.

regional/service/api/environment.yaml
DATABASE_URL: ref:database/main.connectionUrl # an output from another resource
CF_TOKEN: env:CLOUDFLARE_API_TOKEN # a deploy-environment variable
API_KEY: vault:API_KEY # a value from the git-encrypted store
LOG_LEVEL: info # a literal (non-secret config) value

Environment variables are service-scoped: they belong to the service that declares them, and the inforge secret CLI keys the encrypted store by (service, KEY). Two services sharing a container share no secrets — if both need the same value, each holds its own entry and each is rotated independently.

Source DSL

Each entry in environment.yaml is a source DSL string naming where the value comes from — never the value itself. There are four kinds:

ref:<database|compute>/<name>.<output>

A runtime output of another resource:

DATABASE_URL: ref:database/main.connectionUrl
SERVER_IP: ref:compute/bridge.publicIp

The parts are the resource type (database or compute), the resource name, and the output field. A global/ prefix on the name targets the global slice:

DATABASE_URL: ref:database/global/shared.connectionUrl
EDGE_IP: ref:compute/global/edge-01.publicIp

This is the one allowed cross-region reference: a regional service secret may read a global database or compute output, resolving against the global slice regardless of the service's region. See Global resources for the full rules.

env:<VAR>

A value read from the deploy process environment — the same mechanism variables.yaml / regions.yaml use for provider credentials:

CF_TOKEN: env:CLOUDFLARE_API_TOKEN

You inject CLOUDFLARE_API_TOKEN however you like — typically a CI secret mapped to an env var in your workflow (env: { CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} }). An unset or empty value fails the deploy loudly rather than materialising an empty secret. env: is correct for values genuinely external to the deploy; for app secrets that should live in git, prefer vault:.

vault:<KEY>

A value held age-encrypted in git, in the environment's committed secret store (resources/<env>/secrets.enc.yaml), keyed by the declaring service's name and <KEY>:

API_KEY: vault:API_KEY # store key == env-var name
DATABASE_URL: vault:PROD_DB_URL # store key decoupled from the env-var name

The store key is independent of the env-var name, so DATABASE_URL: vault:PROD_DB_URL is valid. Values are written with the inforge secret CLI — the only writer of the store — and encrypted to the store's committed public recipient, so anyone with commit access can add or replace a secret value without any private key (and cannot decrypt what they wrote). At deploy, inforge decrypts the values in CI with the master identity from the INFORGE_SECRETS_KEY environment variable and folds the plaintext into the same resolved map as the other source kinds, delivered to each host as secrets.age.

vault: is the right default for app secrets that should live in git (API keys, signing keys, tokens). A host's secrets.age is a derived cache: it is written only by the deploy, never by the CLI, so it always reflects the last deployed git state (see ADR-0017). inforge validate fails if a vault: secret has no matching ciphertext entry in the env's store, so a missing value is caught before any deploy.

See Creating a secret below for the full workflow.

literal

Any string without a ref:/env:/vault: prefix is used verbatim — useful for non-secret per-service configuration delivered through the same env-var mechanism:

LOG_LEVEL: info
API_BASE: https://api.example.com/v1

:::warning Not for real secrets A literal value is committed in plaintext (it lives in git). Use it for non-secret configuration only; use vault:, env: or ref: for anything sensitive. :::

Creating a secret

A vault: value lives in the env's committed, age-encrypted store. The full lifecycle:

  1. Initialise the store once per environment (see Initialising the store):

    inforge secret init prd
  2. Declare the secret on the consuming service in its environment.yaml:

    API_KEY: vault:API_KEY
  3. Write the value with the inforge secret CLI (the <service> argument is the store key; the value is read from stdin, or --generate mints a random one):

    pbpaste | inforge secret set prd api API_KEY # value from stdin
    inforge secret set prd api SESSION_KEY --generate # random 32-byte value
  4. Commit and merge the updated secrets.enc.yaml. inforge deploy resolves and pushes the value to the consumer's host on merge — never the CLI — so a deploy from main can never roll back a value already delivered.

  5. No separate restart needed. The push is hash-gated: inforge deploy reload-or-restarts the consumer automatically whenever the resolved value actually changes.

Adding the 2nd…Nth secret never touches your workflow — only step 1 (the one-time INFORGE_SECRETS_KEY) does.

Initialising the store

A repository uses the encrypted store once you run inforge secret init <env> for an environment. It creates resources/<env>/secrets.enc.yaml, generates a fresh age key pair, writes the public recipient into the store, and prints the master identity (AGE-SECRET-KEY-…) once:

inforge secret init prd
  • Save the printed identity as the INFORGE_SECRETS_KEY GitHub Actions secret in the consumer repo — the deploy decrypts with it (see the deploy starter).
  • Keep an out-of-band backup: losing it means re-setting every secret in the environment.
  • Commit the generated secrets.enc.yaml. Anyone with commit access can add values against the public recipient with no key material; reading a value requires the master identity, which is never committed.

Full subcommand reference (set, ls, rm, rotate) and incident-response runbooks are on the inforge secret page.

How secrets reach a service

inforge does not bake secret values into any artifact, and there is no runtime secrets backend to configure or operate. At deploy time, for each service that declares secrets, inforge:

  1. Resolves every environment.yaml entry (ref: from stack outputs, vault: by decrypting the store, env: from the deploy environment, literals verbatim, plus any grants: outputs) into one plaintext env/file map.
  2. Age-encrypts that map directly to the target host's own SSH host public key (read live over the same SSH connection inforge already uses to provision the host).
  3. Writes two files onto the host under /etc/wardnet/services/<service>/:
    • descriptor.yaml — a secret-free document naming which env var or file each value belongs to.
    • secrets.age — the age-encrypted plaintext map above (mode 0600).

At service start, inforge-agent (the systemd ExecStart for every service) decrypts secrets.age (and, for a mesh member, its renew-owned leaf.age) with the host's own SSH host key, injects the values as environment variables, drops privilege to the service's user, and execs the real binary. Secret values live only in memory and the service process's environment — never on disk in plaintext, in the journal, or in argv. A service that declares no secrets gets a descriptor with no secrets.age at all and starts with no decrypt step.

A secret rotation takes effect the next time inforge deploy (or inforge pki renew, for mesh/mtls leaves) runs and reload-or-restarts the affected service — there is nothing to configure per region and no secretsStore key in inforge.yaml.