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 — even if you wanted it to.

This page is what that means operationally, and what guarantees you can rely on.

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 the rendered service config on the target machine (a systemd Environment= line) 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.
  • ✅ In the rendered unit file on the target (where the running service needs it).

This isn't a careful-coding promise; it's a property of the architecture. The pure config phase literally cannot see the resolved value — the value is read only at the deploy boundary, where it's injected into the unit file.

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" }
    }
  }]
}

Two secret forms:

  • { from_env = "NAME" } — at deploy time, read the value from os.environ["NAME"] on the deploy machine.
  • { from_file = "/path/to/file" } — at deploy time, read the value from a file on the deploy machine. One trailing newline is stripped (the universal echo "$value" > file pattern).

No other secret syntax. The parser whitelists exactly these two 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, not just the first one. 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

Two characters cannot be safely embedded in a systemd Environment=KEY=VALUE line:

  • \n (newline) — there's no continuation syntax.
  • " (double-quote) — unescapable in this form without ambiguity.

If a secret value contains either character, Punix refuses to render — with a clear error message telling the operator to encode (e.g. base64) at the source. The alternative — escape cleverly — has been a bug-source in every config system that's tried it; refusing is the safer default.

Where the secret value actually lives on disk

The property is "never in the store / derivation / manifest / provenance." The property is not "nowhere" — services need to read the value at start time. The value lives:

  • In the rendered unit file on the target. /etc/systemd/system/api.service contains Environment=DB_PASSWORD=hunter2.
  • Nowhere else.

The unit file is mode 0644 by default. Anyone with read access to /etc/systemd/system/ sees the values. If that's too permissive for your threat model, an opt-in EnvironmentFile= mode (storing values in a 0600 sidecar file) is planned — see in-unit vs sidecar trade-off below.

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=

The current default is Environment=KEY=VALUE lines inside the unit file. A tighter pattern is EnvironmentFile=<sidecar> where the values live in a 0600 file readable only by the service.

Environment= (current default) EnvironmentFile=
Visibility In unit file (cat /etc/.../svc.service) In sidecar file (operator must look)
Process listing Not via ps eww; via /proc/PID/environ for the PID owner Same
Permissions unit file at 0644 sidecar at 0600
Setup cost None One extra file per stack

Both inject the value at service start, and both are equivalent from the service's point of view. The difference is who else on the host can casually see the value.

Punix v0.6 ships Environment=. An opt-in switch to EnvironmentFile= is on the roadmap.

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 in v0.6 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 unit file. 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, not functionality.