Skip to content

Secrets

Most config systems treat secrets as a footnote: "remember to keep them out of the repo." Punix treats secrets as a structural property: the architecture cannot let a secret value reach the build cache, the store, the deploy manifest, or the provenance trail.

The hash-exclusion property

Two deploys differing only in secret values produce byte-identical store paths and byte-identical generation source-hashes. The resolved secret value reaches one file on the target machine (a 0600 systemd EnvironmentFile) and nowhere else:

  • ❌ Not in any store path's hash.
  • ❌ Not in any derivation.json.
  • ❌ Not in the provenance trail.
  • ❌ Not in any gen-NNN.json manifest.
  • ❌ Not in the unit file itself (which is world-readable).
  • ✅ In /etc/punix/env/<service>.env at mode 0600, where the running service needs it.

This is a property of the architecture, enforced by construction. The pure config phase literally cannot see the resolved value: the value is read only at the deploy boundary.

A conformance test verifies this end-to-end: two deploys of the same stack with different DB_PWD env values are checked for byte-identical source_hash, byte-identical store_paths, and byte-identical services records.

The PCL syntax

module ApiStack {
  backend = "systemd"
  storeModules = ["Api"]
  services = [{
    name = "api"
    package = "Api"
    binary = "api"
    environment = {
      LOG_LEVEL    = "info"                       # plain string
      DB_PASSWORD  = { from_env = "DB_PWD" }       # secret
      OAUTH_PRIVK  = { from_file = "/run/secrets/oauth.key" }
    }
  }]
}

Three secret forms, all resolved at deploy time on the deploy machine:

  • { from_env = "NAME" }: read from os.environ["NAME"].
  • { from_file = "/path/to/file" }: read from a file. One trailing newline is stripped (the universal echo "$value" > file pattern).
  • { from_vault = "REF" }: read from a secret store via an oracle you supply with --vault-secrets. REF is opaque to Punix (e.g. secret/data/app#token). See secrets at deploy.

No other secret syntax. The parser whitelists exactly these three record forms; everything else is a normal record.

The error you'll see when a secret is missing

$ punix service deploy ApiStack --file stack.pcl
error: [E11] secret(s) not set: from_env:DB_PWD, from_env:OAUTH_TOKEN, from_file:/run/secrets/jwt

The contract: the deploy fails loudly, naming every missing reference at once. Operators set secrets in batches, and reporting one-at-a-time triples the iteration count. Names are kind-qualified so a same-named env-var-and-file can't collide, and the list is sorted + deduplicated (a stack with the same secret in ten services reports it once).

Values that can't be injected

A newline can never appear in a value. Both Environment= lines and EnvironmentFile lines are line-based, so a newline would forge a second directive in a file that runs as root. Punix refuses to render, and the error tells you to encode (base64, hex) at the source. Escaping cleverly has been a bug source in every config system that tried it.

A double-quote is expressible in the EnvironmentFile (it is escaped on the way out and round-trips), but not in an inline Environment=KEY=VALUE line, where it can't be escaped unambiguously. Since every secret-bearing service routes to the EnvironmentFile, this restriction only reaches non-secret values you chose to render inline.

Where the secret value actually lives on disk

The service has to read the value to start. The value lives:

  • In /etc/punix/env/<service>.env on the target, at mode 0600, containing DB_PASSWORD="hunter2". The unit references it with EnvironmentFile=.
  • Nowhere else.

You don't opt into this. A service whose environment holds a resolved secret is rendered with the sidecar automatically (B3c / ADR-027), because the unit file is world-readable and a secret must not land in it. And you can't opt back out. The kernel owns file modes.

What deploy records about a secret

The generation manifest records that the secret was consumed (which env-var name was read, into which service env variable), without recording the value:

"environment": [
  {"key": "DB_PASSWORD", "kind": "from_env", "name": "DB_PWD"},
  {"key": "LOG_LEVEL",   "kind": "literal",  "value": "info"},
  {"key": "OAUTH_PRIVK", "kind": "from_file", "name": "/run/secrets/oauth.key"}
]

Operationally: an operator running jq .services[].environment on gen-NNN.json sees the deploy's contract (which env vars or files were consumed) without ever seeing the values. Auditable without leaking.

Environment= vs EnvironmentFile=

Both inject the value at service start and are indistinguishable from the service's point of view. The difference is who else on the host can casually read it.

Environment= EnvironmentFile=
Where the value sits in the unit (cat /etc/systemd/system/svc.service) in a sidecar the operator has to go looking for
Permissions unit file at 0644 sidecar at 0600
Double-quote in a value refused fine (escaped)
Process listing not via ps eww; via /proc/PID/environ for the owner same

Which one you get:

Your service Rendered as
any environment value is a resolved secret EnvironmentFile: automatic, not negotiable
environmentFile = true on the service EnvironmentFile: opt in for non-secret values too
otherwise inline Environment= lines

The opt-in exists for values like an internal hostname, a licence key, or an operator's email.

Limitations to be aware of

Secrets in source aren't yet wired

source = { url = "...", token = { from_env = "FETCH_TOKEN" } }: the parser accepts the syntax, but the fetcher doesn't resolve secrets at realise time. Today, secrets are deploy-time only.

Resolving secrets at fetch time has subtle correctness implications: a fetch authenticated by a secret can't be cached on the secret value itself, or hash-exclusion breaks. The fix is in the roadmap; until then, fetch private artifacts via a separate process and reference them locally.

from_file is wired but its conformance test isn't

The deploy resolver handles from_file alongside from_env, and both flow into the rendered EnvironmentFile. The conformance suite only explicitly pins from_env for now; from_file adds a race- condition surface (Vault-style writers, atomic-replace) that deserves its own property test.

In practice, from_file works; the gap is in conformance coverage.