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 /etc/punix/env/api.env at mode 0600, which the unit references with EnvironmentFile=. A resolved secret never lands in the world-readable unit.
  • 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/punix/env/api.env on the target, mode 0600:
DB_PASSWORD="hunter2"
LOG_LEVEL="info"
OAUTH_PRIVK="...resolved-bytes..."
  • /etc/systemd/system/api.service, mode 0644, references it and carries no value of its own:
[Service]
EnvironmentFile=/etc/punix/env/api.env
  • <deployments_root>/ApiStack/gen-NNN.json records 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:

$ 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

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:

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. 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/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 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 0600 env 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
  • 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.