Skip to content

Stateful services

A stack can declare a database (PostgreSQL today) with the schema and roles to ensure exist. This is the hardest deploy gap, because it sits on a tension the rest of the system avoids: everything Punix pins is content-addressed and immutable, but application state is neither. Read the rollback contract below before relying on stateful deploys — it is the soul of this feature.

Declaring a database

module MirrorStack {
  backend = "systemd"
  services = ["Postgres", "Mirror"]
  databases = [
    { engine  = "postgresql"
      service = "postgres"                # the stack service running the engine
      ensureDatabases = ["mirror"]
      ensureUsers = [
        { name = "mirror"
          ensureDBOwnership = ["mirror"]
          password = { from_vault = "secret/mirror#db" } }
      ] }
  ]
}

The declared schema is pure config; applying it (CREATE DATABASE/ROLE) is an effect. Punix renders an idempotent, additive-only provisioning script into a systemd oneshot ordered after the engine service — it never opens a DB connection from the deploy host. The deploy only writes the unit; running it requires activation (--activate), or it fires on next boot.

Idempotent and additive-only

ensureDatabases/ensureUsers are create-if-absent (catalog-guarded). Punix never emits DROP:

  • Removing an entry from the declaration does not drop the database — orphans are logged, never acted on (retraction is irreversibly lossy).
  • It does not reconcile drift: a pre-existing role with different attributes is left as-is. (NixOS's ensure* has the identical limitation.)
  • The one exception: ALTER ROLE … PASSWORD runs every deploy, so a rotated secret takes effect.

A pg_isready gate (ExecStartPre) makes the oneshot wait for the socket to accept connections, so it never races the engine regardless of restart ordering.

DB-user passwords

A password must be a secret reference ({from_env=…} / {from_file=…} / {from_vault=…}) — a plaintext literal is a compose error (it would enter the source and the hash). The delivery is hardened end to end:

  • the superuser leg uses peer/ident auth over the unix socket — no password at all;
  • a user password is resolved at deploy into a 0600 EnvironmentFile, read by psql's \getenv and bound with :'…' — never in the SQL text, never on argv (so not in ps or the journal), never in gen-NNN.json;
  • the ALTER ROLE runs with statement logging suppressed, so the cleartext can't land in the Postgres server log.

(psql ≥ 16 is required for \getenv.)

State lives outside the store

The data dir is a stable host path via systemd StateDirectory= (service surface). It is never a hash input, never pushed, never a GC root, never collected — GC roots are exactly the store paths a generation pins, and a state dir is not one. The generation records that the stack is stateful and the managed state-dir shape (paths, never contents).

Rollback reverts code, not data

A rollback is an atomic flip over binary + config. The data dir is shared across generations and is not reverted. So:

  • An older binary may run against a newer (migrated) schema and refuse to start — a fundamental property of atomic-deploy-with-mutable-state, not a Punix bug.
  • service rollback warns loudly on any stateful transition across the rollback span — including rolling back to a stateless generation, where the data dir is orphaned (not deleted), and a StateDirectory rename, where systemd would create a new empty dir and orphan the old data.
  • The warning informs, it does not block — you may know the transition is safe.

The one-liner: Punix gives you atomic, reversible code and idempotent, additive state setup; it does not give you reversible state. Reversible data is a backup/restore concern — Punix can schedule backups (a Litestream replica, or a pg_dump oneshot on a timer), but the bytes are operator-owned.

Scope

PostgreSQL ships. openbao (a startup-time unseal oracle — a different lifecycle) and harmonia (a plain signing-key secret — no provisioner needed) are deferred. The oneshot model covers one-time idempotent setup only.

Where in the code

  • src/punix/deploy/generators/provision.py — the provisioning oneshot + idempotent SQL.
  • src/punix/deploy/manifest.pyGeneration.stateful / state_dirs.