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.jsonmanifest. - ✅ 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 fromos.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 universalecho "$value" > filepattern).
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.servicecontainsEnvironment=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.
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.