Skip to content

Your first deploy

Goal: take a built package, declare a service stack around it, deploy with punix service deploy, observe the atomic flip, roll back. ~10 minutes.

We'll use --dry-run throughout so nothing touches your real /etc. The exact same code path runs for a production deploy — just drop --dry-run.

A stack module

Continue from first-build. Add a stack module to your PCL file:

hello.pcl (additions)
module Hello {
  pname = "hello"
  version = "1.0"
  recipe = "std.shell"
  recipeArgs = {
    command = "mkdir -p $OUTPUT/bin && echo '#!/bin/sh\nwhile :; do echo tick; sleep 5; done' > $OUTPUT/bin/hi && chmod +x $OUTPUT/bin/hi"
  }
  source = { type = "local" path = "./src" }
}

module HelloStack {
  backend = "systemd"
  storeModules = ["Hello"]
  services = [{
    name = "hello"
    package = "Hello"
    binary = "hi"
    args = []
    dependsOn = []
  }]
  configFiles = [{
    path = "/etc/hello.conf"
    content = "verbose = true\nbind = 127.0.0.1\n"
  }]
}

A stack is just another PCL module — distinguished only by having a backend field. Its job is to compose packages (already-built things in the store) into a runtime configuration:

  • backend = "systemd" — the only one shipping in v0.6. launchd / supervisord / docker-compose land in Stage 8.
  • storeModules: package modules whose store paths must be pinned in the generation's closure (so GC won't collect them).
  • services: per-service records. package references a package module by name (a string, not Hello.out — PCL's type checker doesn't expose .out as a member yet). binary names an executable inside <package_path>/bin/.
  • configFiles: arbitrary text files to write. The systemd generator also emits one /etc/systemd/system/<name>.service per service — those are added automatically.

Dry-run deploy

uv run punix service deploy HelloStack \
  --file hello.pcl \
  --dry-run \
  --store-root /tmp/punix-tutorial/store \
  --deployments-root /tmp/punix-tutorial/deploys

Expected:

deployed: stack HelloStack → gen-001 (2 config file(s), 1 store path(s) pinned)
  scratch: /tmp/punix-tutorial/deploys/.dry-run/

--dry-run does the full deploy pipeline against a scratch filesystem — composes, renders config files, writes the generation manifest, flips a scratch current symlink — except it doesn't touch your real /etc or ~/.punix/deployments. The bytes it would have written land under <deployments-root>/.dry-run/. Inspect:

ls /tmp/punix-tutorial/deploys/.dry-run/etc/
# hello.conf  systemd/

ls /tmp/punix-tutorial/deploys/.dry-run/etc/systemd/system/
# hello.service

cat /tmp/punix-tutorial/deploys/.dry-run/etc/systemd/system/hello.service
[Unit]
Description=Punix stack HelloStack: hello

[Service]
ExecStart=/Users/you/.punix/store/<hash>-hello-1.0/bin/hi
Type=simple
Restart=on-failure

[Install]
WantedBy=multi-user.target

The generation manifest lives at:

cat /tmp/punix-tutorial/deploys/HelloStack/gen-001.json

This file is the rollback contract: every store_path pinned, every config file's sha256, the source hash, the deploy timestamp. Rollback never re-evaluates — it reads gen-NNN.json and flips the current symlink. O(1).

Deploy twice, then roll back

Re-run the same command — exact same arguments:

uv run punix service deploy HelloStack \
  --file hello.pcl --dry-run \
  --store-root /tmp/punix-tutorial/store \
  --deployments-root /tmp/punix-tutorial/deploys
deployed: stack HelloStack → gen-002 (...)

Even with identical content, a new generation is recorded (the manifest records every deploy, not just content-changing ones — that's deliberate).

Now roll back:

uv run punix service rollback HelloStack \
  --store-root /tmp/punix-tutorial/store \
  --deployments-root /tmp/punix-tutorial/deploys \
  --transport-root /tmp/punix-tutorial/deploys/.dry-run
rolled back: stack HelloStack → gen-001 (was gen-002)

The current symlink on the (scratch) target now points back at gen-001.json. Verify:

readlink /tmp/punix-tutorial/deploys/.dry-run/tmp/punix-tutorial/deploys/HelloStack/current
# gen-001.json

gen-002.json is still on disk — rollback is "flip the pointer", not "delete forward generations". You can roll forward by atomic_switch to 2 again (CLI command for that is Stage 8 polish).

What just happened

Three properties got exercised end-to-end — each is a CI release-blocker in the conformance suite:

  1. Atomic flip — the live state changes in exactly one os.replace of current. Crash at any earlier point and the previous generation stays wholly live.
  2. Rollback exact — gen-001 was restored byte-identically. The forward generation (gen-002) survived on disk; you can re-deploy to flip back.
  3. GC respects pins — the next punix store gc will see both gen-001 AND gen-002 in the keep-set; the Hello store path survives both.

Reference: conformance explains how each maps to the test suite.

What's next

  • Real deploy (drop --dry-run): you'll need write access to /etc/ (i.e. root on most systems). See composing a stack for the realistic flow.
  • Remote deploy over SSH: add --target ssh://user@host. See Deploy over SSH.
  • Secrets: add environment = { DB_PASSWORD = { from_env = "DB_PWD" } } to a service. See Secrets at deploy.
  • Understand the design: Eval/realise seam walks through the one boundary the whole system is organised around.