Skip to content

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" }
    }
  }]
}
DB_PWD=hunter2 \
  punix service deploy ApiStack --file stack.pcl

The resolver:

  • Reads $DB_PWD from os.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"}) in gen-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.

environment = {
  API_TOKEN = { from_vault = "secret/data/app#token" }
}

The shipped adapter reads a flat JSON {ref: value} export — a dump the operator produces out of band — passed with --vault-secrets:

punix service deploy ApiStack --file stack.pcl --vault-secrets ./vault-export.json

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.service on the target contains: Environment=DB_PASSWORD=hunter2 Environment=OAUTH_PRIVK=...resolved-bytes... Environment=LOG_LEVEL=info
  • <deployments_root>/ApiStack/gen-NNN.json contains: 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:

$ punix service deploy ApiStack --file stack.pcl
error: [E11] secret(s) not set: from_env:DB_PWD

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 in KEY=VALUE form 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:

DB_PWD=$(printf 'multi\nline' | base64) \
  punix service deploy ApiStack --file stack.pcl

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/key appends \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 is echo > 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
  • services across 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.json even after a successful deploy.