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/deploymentsby default; pass--deployments-rootto point elsewhere). - Write access to the store paths' parent (the deploy will rsync the closure into
~/.punix/store/— or wherever--store-rootpoints).
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:
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:
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:
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 realsshdper 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.
Related¶
- Concepts: Transport and backends — the seam this rides on.
- Rollback — including SSH-rollback caveats.
- Reference: decisions.