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.jsonmanifest. - ❌ Not in the unit file itself (which is world-readable).
- ✅ In
/etc/punix/env/<service>.envat mode0600, 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 fromos.environ["NAME"].{ from_file = "/path/to/file" }: read from a file. One trailing newline is stripped (the universalecho "$value" > filepattern).{ from_vault = "REF" }: read from a secret store via an oracle you supply with--vault-secrets.REFis 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>.envon the target, at mode0600, containingDB_PASSWORD="hunter2". The unit references it withEnvironmentFile=. - 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.
Related¶
- Deploy: secrets at deploy: the operator how-to (where to set the env var, how to handle E11).
- Language: secrets in PCL: the syntax details.
- Reference: conformance: the hash-exclusion test among others.