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 the systemd unit's
Environment=lines on the target. - 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/systemd/system/api.serviceon the target contains:Environment=DB_PASSWORD=hunter2 Environment=OAUTH_PRIVK=...resolved-bytes... Environment=LOG_LEVEL=info<deployments_root>/ApiStack/gen-NNN.jsoncontains:json "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 unit file on the target. Not the store, not the canonical derivation, not the provenance, not the manifest.
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¶
Two characters cannot be safely embedded in a systemd Environment= line:
\n(newline) — systemd has no continuation; one variable per line."(double-quote) — unescapable inKEY=VALUEform without ambiguity.
The systemd generator refuses to render those values. Located error:
$ DB_PWD=$'multi\nline' punix service deploy ApiStack --file stack.pcl
error: service 'api': environment['DB_PASSWORD'] contains a newline —
systemd Environment= can't express this. Encode the value (e.g.
base64) at the source.
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 need it. The value lives on the target only in the rendered unit file (which is at 0644 — see the in-unit vs sidecar trade-off below).
Environment= vs EnvironmentFile=¶
v0.6 ships Environment= (in-unit). The unit file is 0644 by default; its contents are visible to any user who can read /etc/systemd/ system/. A tighter pattern is EnvironmentFile=<sidecar> where the values live in a 0600 file.
Trade-off:
Environment= (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 |
Service environment values ship as Environment= (in-unit). The tighter EnvironmentFile= (0600) pattern already ships for the one case where it is load-bearing — stateful database passwords (see Stateful services), where the secret is written to a 0600 file and read by psql, never placed in the unit or on argv. A general opt-in to EnvironmentFile= for ordinary service env remains future work.
→ 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 are real: 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 unit, 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.