Secrets at deploy¶
The architectural story is in Concepts: secrets. The PCL surface is in Language: secrets in PCL. This page is the operator recipe.
The deploy flow¶
# stack.pcl
module ApiStack {
backend = "systemd"
storeModules = ["Api"]
services = [{
name = "api"
package = "Api"
binary = "api"
environment = {
LOG_LEVEL = "info"
DB_PASSWORD = { from_env = "DB_PWD" }
OAUTH_PRIVK = { from_file = "/run/secrets/oauth.key" }
}
}]
}
The resolver:
- Reads
$DB_PWDfromos.environ→ resolved to"hunter2". - Reads
/run/secrets/oauth.key(must exist on the deploy machine at this path) → strips one trailing\n. - Writes both into
/etc/punix/env/api.envat mode0600, which the unit references withEnvironmentFile=. A resolved secret never lands in the world-readable unit. - Records the reference (
{kind: "from_env", name: "DB_PWD"}) ingen-NNN.json. The resolved value never enters the manifest.
from_vault; centralised secrets¶
A third reference kind, { from_vault = "REF" }, resolves from a secret store instead of the deploy host's environment or filesystem. REF is opaque (e.g. a Vault path like secret/data/app#token) and, like the others, the value never enters the hash, store, or gen-NNN.json; only the reference is recorded.
The shipped adapter reads a flat JSON {ref: value} export: a dump the operator produces out of band; passed with --vault-secrets:
Without --vault-secrets, any from_vault reference is reported as [E11], exactly like an unset env var. A live Vault network client is a deferred adapter behind the same callable seam.
What's where after deploy¶
/etc/punix/env/api.envon the target, mode0600:
/etc/systemd/system/api.service, mode0644, references it and carries no value of its own:
<deployments_root>/ApiStack/gen-NNN.jsonrecords the references:
"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"}
]
The value hunter2 exists in one place: the 0600 env file on the target. Not the unit, not the store, not the canonical derivation, not the provenance, not the manifest.
Note that the non-secret LOG_LEVEL moved to the sidecar too; the choice is per service, and one secret pulls the whole environment out of the unit.
E11; every missing secret in one message¶
If $DB_PWD isn't set when you run the deploy:
If multiple secrets are unset:
$ 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: deploy MUST fail naming every missing variable; not the first one. Operators set them in batches; reporting one-at-a-time triples iteration count.
Names are kind-qualified (from_env:NAME / from_file:/path) so a same-named env-and-file can't collide. The list is sorted + deduplicated: a stack with the same secret in ten services reports it once.
Forbidden value characters¶
A newline is never allowed, in either rendering. Both forms are line-based, so a newline would forge a second directive in a file that runs as root:
$ DB_PWD=$'multi\nline' punix service deploy ApiStack --file stack.pcl
error: service 'api': value of 'DB_PASSWORD': '"multi\nline"' contains a
newline — it would forge an extra record/directive on the following line —
a systemd EnvironmentFile is line-based; encode the value (e.g. base64) at
the source.
A double-quote is fine in a secret. It's escaped in the EnvironmentFile and round-trips exactly. It is refused only in an inline Environment=KEY=VALUE line, where it can't be escaped unambiguously; secrets never render inline.
Encode (base64, hex) at the source if you need a newline. Escaping cleverly has been a bug source in every config system that tried it.
Operator workaround:
And on the service side, base64-decode at startup. The alternative (silently escape cleverly) has been a bug-source in every config system that's tried it. We chose the louder failure.
SSH deploys¶
Secrets resolve on the deploy host (where os.environ and the local fs are). The resolved values then flow over SSH into the remote's unit file:
DB_PWD=hunter2 \
punix service deploy ApiStack \
--file stack.pcl \
--target ssh://deploy@prod.example.com \
--key ~/.ssh/punix-deploy
The deploy host needs $DB_PWD set; the target host does not. On the target the value lives only in the 0600 env file.
Environment= vs EnvironmentFile=¶
Environment= |
EnvironmentFile= |
|
|---|---|---|
| Visibility | in the unit (cat /etc/.../svc.service) |
in a sidecar the operator must go looking for |
| Permissions | unit 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 |
You don't choose between them for a secret. Any service whose environment holds a resolved secret renders as EnvironmentFile automatically (B3c / ADR-027): the mode belongs to the kernel, not the recipe. Setting environmentFile = true opts a service in for non-secret values as well. Everything else renders inline.
The same discipline shows up wherever a password reaches a daemon: stateful database provisioning (see Stateful services) writes to a 0600 file read by psql, never to the unit and never onto argv.
→ Reference: decisions for the full rationale.
from_file; common gotchas¶
- Trailing newline strip.
echo "value" > /run/secrets/keyappends\n: the resolver strips exactly one trailing\n. If you need a literal trailing newline, the recipe doesn't support it (intentional: the universal case isecho > file). - File must exist at deploy time. Race conditions with Vault-style secret managers happen: if Vault writes the file after Punix tries to read it, you get
[E11]. Sequence the deploy after the secret provisioner. - The deploy reads, not the service. The bytes flow through the resolver, into the rendered
0600env file, onto the target. If the target service then re-reads the file at runtime, that's its own concern; Punix doesn't intermediate.
Conformance¶
tests/c_e2e/test_conformance_stage6.py pins three properties:
- Hash-exclusion: byte-identity of
source_hash+store_paths servicesacross two deploys with different secret values.[E11]collects every missing reference (3-secret stack, all unset).- Sentinel grep: the secret value never appears in
gen-NNN.jsoneven after a successful deploy.
Related¶
- Concepts: secrets: the hash-exclusion property.
- Language: secrets in PCL: the syntax.
- Reference: error codes E11.
- Reference: decisions.