Skip to content

Deploy over SSH

Punix's SSH transport (Stage 5) lets you deploy a stack to any host that accepts ssh + rsync. The deploy code path is the same one used locally — SshTransport substitutes for LocalTransport, nothing else changes.

The minimal recipe

punix service deploy MyStack \
  --file pkgs/ \
  --target ssh://deploy@prod.example.com \
  --key ~/.ssh/punix-deploy

Flags:

  • --target ssh://USER@HOST[:PORT] — the remote target. Mutually exclusive with --transport-root.
  • --key /path/to/private_key — optional. Default falls back to ssh-agent / the user's ~/.ssh/id_*.
  • --known-hosts /path/to/known_hosts — optional. Default uses the user's ~/.ssh/known_hosts. Pinning a different file is useful for CI / per-environment isolation.

The user deploy on prod.example.com needs:

  • Write access to wherever the unit files land (/etc/systemd/system/ hardcoded in v0.6; needs root or a sudoers rule).
  • Write access to <deployments_root> (~/.punix/deployments by default; pass --deployments-root to point elsewhere).
  • Write access to the store paths' parent (the deploy will rsync the closure into ~/.punix/store/ — or wherever --store-root points).

What happens, step by step

1. compose_stack(ev, "MyStack")           # local — pure
2. resolve_secrets(composed)              # local — reads $env
3. systemd.render(resolved.stack)         # local — pure
4. _push_store_closure(SshTransport,      # over ssh
       composed.store_paths)              # only missing paths transfer
5. _write_config_files(SshTransport, …)   # over ssh — per-file atomic
6. manifest.deploy(stack, gen)            # writes gen-NNN.json over ssh
       └─ atomic_symlink_switch(...)      # over ssh — atomic flip

The closure push step is content-addressed (Stage 5b):

for store_path in stack.store_paths:
    if not transport.exists(store_path):    # remote check via ssh test -e
        transport.push(store_path, store_path)   # rsync the missing path

For a path that already exists on the target with the same hash-name, existence IS correctness — same hash ⇒ same bytes by construction. No per-file checksum needed. The deploy summary reports N pushed/M cached:

deployed: stack MyStack → gen-002 (3 config file(s), 5 store path(s) pinned, 1 pushed/4 cached)

Strict host-key checking

SshTransport always passes -o StrictHostKeyChecking=yes. If the target's host key isn't in your known_hosts, deploy fails with ssh's Host key verification failed. error.

Add the key first:

ssh-keyscan -H prod.example.com >> ~/.ssh/known_hosts

For hermetic CI deploys, pin a per-environment known_hosts:

punix service deploy MyStack \
  --target ssh://deploy@stage.example.com \
  --known-hosts ./ci/stage-known_hosts

(The fixture-controlled known_hosts pattern is exactly what the SSH conformance tests use; see tests/c_e2e/conftest.py::localhost_sshd.)

What lands on the remote

After a successful deploy:

prod.example.com:
  ~/.punix/store/                   # closure pushed here
    <hash1>-api-1.0/                  bin/, built, derivation.json, …
    <hash2>-worker-1.0/               …
  ~/.punix/deployments/
    MyStack/
      gen-001.json
      gen-002.json
      current → gen-002.json        # the atomic flip lives here
  /etc/myapp/app.conf               # stack-declared config files
  /etc/systemd/system/api.service   # systemd-generator output
  /etc/systemd/system/worker.service

You still have to systemctl daemon-reload + systemctl restart api yourself in v0.6 — the lifecycle commands are wired through Transport (transport.run(["systemctl", "daemon-reload"]) works), but the deploy CLI doesn't invoke them. Stage 8 asserts the running service.

Rollback over SSH

Same command shape:

punix service rollback MyStack \
  --target ssh://deploy@prod.example.com \
  --key ~/.ssh/punix-deploy

The rollback's verify_pinned_paths step currently uses the local Store(store_root) — a known limitation (see status: limitations). For a shared deploy-side/target filesystem (localhost-sshd fixtures), this is fine; for a real cross-host deploy where the target's store was GC'd independently, the check would miss it. Fix is Stage 8 — route the existence check through Transport.

Cross-arch deploys

If your dev host is x86_64-darwin and prod.example.com is aarch64-linux, the closure has been built for the wrong arch. v0.6 deploys clearly fail at the closure-push step (the pushed paths are x86 binaries; running them on aarch64 ⇒ "exec format error" at service-start time).

Until Stage 8's cross-arch build farm lands, the workaround is to build on a host with the right arch:

# On an aarch64 jumpbox
punix build pkgs/
# Then deploy from there
punix service deploy MyStack --target ssh://deploy@prod.example.com

The CI matrix on aarch64 (Stage 6g) makes this fully viable — ubuntu-24.04-arm runners exist and can do the build.

Conformance

  • The atomic-flip and rollback-exact properties hold over SSH — see tests/c_e2e/test_conformance_stage5_ssh.py.
  • The localhost-sshd fixture (tests/c_e2e/conftest.py) spins up a real sshd per test on a free port — auto-skips when sshd / ssh-keygen aren't on PATH (bare CI runners).

Where in the code

  • src/punix/deploy/transport.py::SshTransport — the implementation.
  • src/punix/cli.py::_parse_ssh_target — URL parsing.
  • src/punix/cli.py::_push_store_closure — Stage 5b's contract.