kampodine
v0.6.0
Published
Kamal-style deploys for Alpine + Podman hosts, built on kamal-proxy
Readme
kampodine
kamal + podman + alpine — Kamal-style deploys for Alpine + Podman hosts, built on kamal-proxy.
Why this exists
kamal is a great deploy tool with one hard requirement pair: Docker engine and systemd on the target host. If you run Alpine Linux — or a strict-podman fleet with no Docker anywhere — kamal cannot deploy for you:
- Alpine ships no systemd at all. Not in main, community, or edge (verified on v3.22). OpenRC is the init system, period.
- Not every fleet runs Docker. Rootful Podman under OpenRC
supervise-daemonis a perfectly good way to host containers — kamal just can't talk to it. - kamal-proxy was never the problem. It's a container image — completely
init-agnostic. Host routing, ACME TLS, and gapless target swaps via
kamal-proxy deploywork fine on an Alpine + Podman host.
kampodine keeps kamal-proxy as the TLS edge and replaces everything around it with Alpine-native pieces:
| kamal assumes | kampodine uses |
|---|---|
| Docker engine | Podman (rootful, no Docker anywhere) |
| systemd units | OpenRC supervise-daemon services |
| Container registry | podman save \| ssh podman load — a plain SSH stream |
| kamal proxy orchestration | kamal-proxy itself, unchanged |
What it does
Same mental model as kamal: build → ship → health-gate → cutover → instant rollback.
kampodine deploy:
podman build --build-arg BUILD_ID=<git sha> # committed tree only
podman save <img>:<sha> | ssh <host> podman load
podman tag <img>:<sha> <img>:latest # on the VM
env file generation -> /etc/<app>/env (0600) # from your secret store
rc-service <app> restart
health endpoint must report the DEPLOYED sha # health gate
kamal-proxy deploy <host> -> <target> # gapless cutover
public smoke: health + build-id endpointskampodine config— per-instance profiles in~/.kampodine/config.json(dir 0700, file 0600):init --name prod --host root@<ip> --ssh-key <path> [--proxy-host <h>] [--group main] [--set-default],list(+--groupcosmetic filter, default marker, sshKey shown as PATH only),show,set-default,remove(the last profile needs--force). FLAT — no inheritance between profiles (the schema leaves room for a futuredefaultsblock; nothing reads one). Resolution everywhere:--profileflag |KAMPODINE_PROFILE| defaultProfile | the legacy env vars — a profile's host/sshKey/proxyHost feed the same ladders every subcommand already useskampodine metrics— one-shot VM snapshot over one SSH round-trip: load + CPUs, memory, disk with an images/data/other breakdown, per-container CPU/MEM, uptime, top-5 by RSS;--warn-disk <pct>(default 90) exits 1 at the threshold (the deploy gate's verdict logic);--watch Nto loop.status --verbosefolds the same snapshot in. One-shot CLI view only — continuous monitoring belongs to Beszelkampodine backup— OCI Object Storage backup family (--bucket, defaultesellar-libsql-backups; oci config-file--profileor--instance-principalauth ONLY — never stored credentials):list(sizes + timestamps; LIST-denied prints the missing-policy guidance),download(GET works without LIST; sha256/MD5 verification against object metadata when present; output 0600; fingerprint summaries, never contents),verify(read-only: pipes the local .db — or every .db member of a .tgz, extracted to a scratch dir — over ssh stdin into the VM's/usr/local/bin/restore-verify; NEVER writes into /data/tenants), andrestore-plan(prints the documented stop/swap/start sequence; never executes anything)kampodine deploy— stream deploy;--version <sha>restreams an existing build;--rollback [sha]is an instant image-tag rollback (the previous image stays on the VM for exactly this). Every run appends to the local deployment ledger and ends with a total-deployments footer; a disk guard reads the VM'sdfbefore the build (LOUD warning above 90%,--require-disk <pct>fails closed)kampodine deploy <lifecycle>— the deployment lifecycle as native subcommands (same--host/--ssh-keyresolution as deploy):listmerges the VM's sha-tagged images (running one marked) with the local ledger (~/.kampodine/deployments.jsonl— append-only history of every deploy/rollback: ts, host, sha, tag, result, duration, git subject; never secrets) into a table with aTotal deployments: Nfooter;prune [--keep N] [--dry-run]reclaims VM disk by removing old sha-tagged images while ALWAYS keeping the running image,ts-rollback, and the newest N (the 2026-10-08 disk-full outage is the reason this exists);logs [--lines N],restart(rc-service, deploy's own restart path), andshell(interactive sh, orexec -- <cmd>) end the hand-rolled-ssh erakampodine env— manage the remote app env file (/etc/kampodine/env, 0600 root) without ever printing a secret:listshows KEY + fingerprints only (value length + first 2 chars),push --file <env>uploads over ssh stdin into a 0600 temp + atomicmv,pullstreams the raw payload to stdout or--out(written 0600). Host/key resolution is identical to deploy:--host root@<ip>|KAMPODINE_HOST,--ssh-key <path>|KAMPODINE_SSH_KEY| ssh-agent.env fingerprintpreviews the masking for any local file — values never leave stdinkampodine dns— OCI DNS records for the post-sslip.io era:records,add --name <label> --type A|AAAA|CNAME --value <target> [--ttl 300],rm. Shell out to theociCLI; auth is the OCI config file (--profile) or--instance-principalONLY — kampodine never accepts, stores, or logs credential material.addmerges into the existing RRSet (round-robin survives),rmfilters it; types/ttl/value are validated locally before any provider callkampodine bluegreen status|init|provision|flip|rollback— reserved public IP management for a two-instance blue/green pair on OCI: zero DNS change, health-gated flips with an ACME-first cutover (reserved IP assigned → cert issued for its hostname → served sha verified) and automatic rollback. A guest anchor watcher installed byvm-prepareholds the flip's IP half — add-only and inert until a flip writes its anchor config. Every sub-step has its own--helpwith usage + exampleskampodine status— deployment count (from the ledger), live health through the proxy, and — with--host— VM disk usage (loud warning above 90%), the sha-tagged image count with a reclaimable-by-prune estimate, a service roll call (app, kamal-proxy, walshipper if present), plus the blue/green pair view;--verboseadds the full metrics snapshotkampodine vm-prepare— first-run bootstrap of a bare Alpine host: sshd hardening (fresh VMs pass unattended), busybox-wget health probes (the golden image ships no curl), the blue-green anchor watcher, the podman stack, and OpenRCsupervise-daemonunits for app + kamal-proxykampodine image-import— golden qcow2 → OCI custom image. Caveat: imported custom images boot BIOS and are rejected by Ampere (A1) shapes — usebluegreen provisionfor ARM targetskampodine migrate— sqlite migrations over SSH
Help everywhere: kampodine --help (or bare kampodine) prints a
grouped command index (DEPLOY / DEPLOY LIFECYCLE / INFRA / DNS / ENV /
CONFIG / METRICS / BACKUP, vercel-style); every command — and every
deploy-lifecycle/bluegreen/env/dns/config/backup sub-step — answers
--help with usage + examples. No subcommand silently does nothing on
--help.
Host init auto-detection: on first connect kampodine probes
rc-service (Alpine/OpenRC) vs systemctl (systemd) and caches the
verdict in the active profile entry; service restarts and the status roll
call emit the right commands for the detected init (svc_action).
Alpine behavior is byte-identical to always. Groundwork for non-Alpine
targets — no per-distro bootstrap.
Kamal parity
| kamal | kampodine |
|---|---|
| kamal deploy | kampodine deploy |
| kamal rollback | kampodine deploy --rollback [sha] |
| kamal details | kampodine bluegreen status |
| kamal proxy … | kamal-proxy container, unchanged |
| kamal app logs | kampodine deploy logs |
| kamal app exec | kampodine deploy shell (or deploy shell exec -- <cmd>) |
| (no equivalent) | kampodine deploy list / deploy prune / deploy restart / status |
| kamal reboot / app boot | rc-service <app> restart over SSH (init-aware: systemctl on systemd hosts) |
| kamal's per-env config files | kampodine config profiles (--profile everywhere) |
| (no equivalent) | kampodine metrics / backup list|download|verify|restore-plan |
Building the expected image
kampodine's target host is a converged Alpine image — Alpine (aarch64 or x86_64) with the podman runtime, OpenRC services, and kernel settings the deploy flow relies on. The reference build pipeline:
- packer + qemu (UEFI) boots the Alpine
virtISO. Boot is driven over the serial console, not VNC — the ISO's VGA tty has no getty; a serial bootstrap helper handles the login prompt and ash'sESC[6ncursor query, then types one line that brings up DHCP and fetches the bootstrap script. - install runs
setup-diskunder UEFI (ESP + grub-efi,--removablefallback for hosts with fresh NVRAM), sanitizes apk repos, installspython3(ansible runtime) +openssh, enables boot-critical OpenRC services, bakes your public key, reboots. - ansible converge installs the podman stack — podman, netavark,
aardvark-dns, iptables (netavark needs it), the cgroups service
(cloud VMs boot without one),
ip_unprivileged_port_start=80sysctl — and lays downsupervise-daemontemplates for app + kamal-proxy. The same playbook converges the golden image and repairs live drift: one convergence path, the image is by construction what the playbook produces. - ship the qcow2 to your cloud. On OCI: import from a launched
instance (or boot-volume seeding) — raw QCOW2 object imports land
x86-flagged and won't boot on Ampere.
kampodine image-import+kampodine vm-preparehandle that path. - First deploy issues TLS (ACME) via kamal-proxy — no cert carry.
Verify a build by booting the qcow2 locally (qemu UEFI boot, serial console) before importing — the image should reach a working sshd with the podman stack installed.
Shipping the image: mirror vs no-mirror
Two ways to get the built container image onto the target — kampodine automates the second; the first is a documented recipe:
No-mirror (default): the SSH stream. podman save | ssh podman load over
the deploy's own SSH connection. Nothing exists between you and the VM: no
registry, no tunnel, no extra ports, no credentials beyond SSH.
- ✅ zero infrastructure; works air-gapped; nothing to keep alive
- ✅ sha-tagged images land atomically;
podman loaddedupes layers by digest - ⚠️ each deploy streams the full image tar; no cross-deploy layer cache
Mirror (optional): any OCI registry. podman push the sha-tagged image to
a registry the VM can reach; the VM podman pulls and retags :latest — the
health-gate/cutover tail is identical. Works with hosted registries (GHCR,
Docker Hub, cloud registries) or a local one mirrored over an SSH reverse
tunnel (ssh -R 5000:127.0.0.1:5000).
- ✅ layer caching (fast repeated deploys), multi-target fan-out, CI-native
- ⚠️ requires registry reachability + credentials on the VM
- ⚠️ macOS note: AirPlay listens on :5000 by default — pick another port for a local registry
Rule of thumb: one VM and no existing registry → stream. Multiple targets, CI-driven deploys, or a registry you already run → mirror. Registry-path automation is on the roadmap; today the stream is the automated path.
Prerequisites
Deploy machine: node ≥ 20, podman, ssh key access to the target, oci CLI
(for bluegreen / image-import / dns / backup), jq (for dns record surgery
- profile config reads), and whatever secret-resolution your env-file step uses (kampodine is agnostic; the reference setup uses varlock + pass).
Target: a converged Alpine + Podman + OpenRC host (see above), reachable over ssh as root, with kamal-proxy running.
Status
Proven on production deployments (v0.2.x). Defaults are still opinionated —
service/container names, env-file path, deploy-host env — and genericizing
them is the top roadmap item. Design reference + roadmap:
kampodine.md. Release history:
CHANGELOG.md.
License
Copyright (c) 2026 talha7k — AGPL-3.0 (see LICENSE).
Copyleft with teeth: anyone who uses, modifies, or hosts kampodine — including as a network service — must publish their source under AGPL-3.0, and original attribution must be preserved.
