README
¶
spod
A daemonless, rootless OCI container engine and embeddable Go library,
Apache-2.0 licensed. Images pull straight from registries via
go-containerregistry; Linux containers run through runc/libcontainer.
No daemon, no root service, no separately installed runtime.
Quick Start
Install the latest release binary and run a container — no toolchain needed on the machine (the release page carries one file per architecture):
curl -fsSL https://github.com/stewlab/spod/releases/latest/download/spod-linux-amd64.tar.gz | tar -xz -C /tmp
install -m 755 /tmp/spod ~/.local/bin/spod
spod --version
spod pull docker.io/library/alpine:3.22
spod run --rm -it alpine:3.22 /bin/sh
Containers run on Linux — the engine needs the kernel (user namespaces,
runc/libcontainer). The darwin, windows, and freebsd builds manage images
and the store (pull, import, export, prune) and report an error for the
container lifecycle. Archives are spod-<os>-<arch>.tar.gz (.zip for
windows) plus a checksums.txt; curl-installed binaries are not
quarantined by macOS Gatekeeper. Building from source, including
cross-compiling, is below.
Building from Source
Build in a container — no Go toolchain or C compiler needed on the host:
podman build --target binary --output type=local,dest=bin . # emits bin/spod
mkdir -p ~/.local/bin && install -m 755 bin/spod ~/.local/bin/spod
spod --version
podman build --tag spod . # builds the distribution image
podman build --target test . # runs the test suite
install (rather than cp) replaces the file by rename, so the install
succeeds even while a previous spod process is still running from the old
binary — cp fails with "Text file busy" in that situation.
The binary is fully static (CGO against musl), -trimpath, stripped
with -s -w. --target binary exports just the binary into bin/; the
default build finishes at the minimal Alpine image packaging the engine
for distribution. --target test runs the suite in-container; a failing
test fails the build.
Once one spod binary exists, it can build the next one inside its own container:
spod run --rm -v "$PWD:/src" -w /src golang:1.25-alpine \
sh -c 'apk add --no-cache git gcc musl-dev linux-headers &&
GOWORK=off CGO_ENABLED=1 go build -trimpath -tags netgo,osusergo \
-ldflags "-s -w -linkmode external -extldflags -static" -o bin/spod ./cmd/spod'
install -m 755 bin/spod ~/.local/bin/spod
spod --version (one line) and spod version (full build table) name the build: the tag when built from one (a
go install github.com/stewlab/spod/cmd/spod@v0.1.0 binary included),
dev-<commit> for checkout builds — -dirty when the checkout carries
uncommitted changes — and dev when no git metadata is available.
Build on the host instead if you have Go 1.25+, a C compiler, and a static libc:
GOWORK=off CGO_ENABLED=1 go build -trimpath -tags 'netgo osusergo' \
-ldflags '-s -w -linkmode external -extldflags "-static"' -o bin/spod ./cmd/spod
GOWORK=off go test ./...
install -m 755 bin/spod ~/.local/bin/spod
Cross-compiling
The Linux targets cross-compile in the container: GOARCH selects amd64
(default), arm64, riscv64, 386, arm — zig cc carries musl, no per-target
toolchain, no emulation; Alpine's package pin holds the Zig version.
podman build --build-arg GOARCH=arm64 --target binary --output type=local,dest=bin .
podman build --build-arg GOARCH=riscv64 --target binary --output type=local,dest=bin .
The darwin, windows, and freebsd targets build with plain Go and CGO off
— no Apple SDK needed: image pulls, store management, and import/export
work, while the container lifecycle reports ErrUnsupported. The
release workflow ships all of them.
Testing the full lifecycle
Tests that run real containers are opt-in; this exercises the full
lifecycle against docker.io/library/alpine:3.22, while plain tests stay
offline:
SPOD_REAL_LIFECYCLE_TEST=1 GOWORK=off go test ./pkg/engine/ -run TestRealLifecycleAlpine
Commands
spod pull docker.io/library/alpine:3.22
spod build -t localhost/app:dev --build-arg VERSION=1.2 .
spod run --rm -it -v "$PWD:/workspace" -w /workspace alpine:3.22 /bin/sh
spod run -d --name demo alpine:3.22 sleep infinity
spod run --rm --userns keep-id -u "$(id -u):$(id -g)" -v "$PWD:/w" alpine:3.22 touch /w/f
spod exec -it demo /bin/sh
spod attach demo
spod inspect --format '{{.Status}}|{{.Image}}' demo
spod ps
spod stop demo && spod start demo
spod logs --tail 20 demo
spod rm demo
spod rename demo demo2
spod images
spod rmi alpine:3.22
spod volume ls && spod volume rm demo-home
spod prune # or: spod prune --force
spod export demo -o demo-rootfs.tar
spod import probe/base:latest demo-rootfs.tar
spod import-oci alpine:3.22 alpine-saved.tar
buildbuilds an image fromCONTEXT/Containerfile(elseDockerfile;-fpicks another) without a daemon or BuildKit. Every instruction is supported, including multi-stage builds,--target,COPY --froma stage or image,COPY/ADD --chown/--chmod,ADDof URLs and local tar archives,ARG/--build-arg, and.containerignore/.dockerignore.RUNsteps execute as rootless containers with host networking and Podman's default capabilities; stages the target does not need are skipped. Not supported: heredocs,RUN --mount/--security,RUN --networkother thanhost, andONBUILDtriggers of base images. There is no layer cache — each build starts from the base image — and the result is one squashed layer.- Containers get Podman's default capability set (
CHOWN,DAC_OVERRIDE,FOWNER,FSETID,KILL,NET_BIND_SERVICE,SETFCAP,SETGID,SETPCAP,SETUID,SYS_CHROOT), effective for root and a bounding set for other users so setuid tools such assudowork; all of it is confined to the user namespace.-u/USERaccept names from the image's/etc/passwdand/etc/group. exportsnapshots a container's root filesystem, changes included;importturns an archive into a stored image, making captured filesystems reproducible starting points.import-ocistores a registry image from an archive — podman or dockersaveoutput, either OCI layout or docker-save format — which is how images land in the store from another engine's storage without a download.run --userns keep-idmaps your UID/GID to the same IDs inside the container, like podman's keep-id: files you create in bind mounts as that user are yours on the host, while container root and every other ID map to your subordinate range, so an entrypoint can start as root, add a user with your UID, and drop to it. It needsnewuidmap/newgidmapand a/etc/subuidand/etc/subgidrange larger than your UID; the rootfs is re-owned once at creation.renamechanges a container's name in place, mount definitions included;volume ls/volume rmmanage named volumes.pruneremoves stored images no container uses. It lists what would go and asks first (default No); scripts pass--force. Images still in use are never offered, and removals land in the store's trash either way.attachstreams a detached container's live output and, when it was created withrun -di, forwards stdin. Detached TTY is not supported; useexec -itfor a terminal.- Storage follows the platform data directory (
~/.local/share/spod/storageon Linux). Override withSPOD_ROOTor--root; overrides must be absolute. Removed resources move to the store'strash/directory rather than being deleted outright.
Embedding
pkg/engine exposes image and lifecycle operations; pkg/cli provides a
Cobra command tree. Every embedding executable must call engine.Init()
as its first operation in main — libcontainer re-executes the binary to
bootstrap containers. cli.StyleHelp(cmd) applies the shared help styling
to any command tree.
func main() {
engine.Init()
// ...application setup and commands
}
Contributing & Releases
Development happens on short-lived feature branches off main, merged
through pull requests gated by CI. The conventions live in
CONTRIBUTING.md; the maintainer release runbook — how tags become
releases and how versions are stamped — is RELEASING.md.
Boundaries
- Host networking only; isolated network modes are rejected, not silently
mapped to host. The host's
/etc/resolv.confand/etc/hostsare bind- mounted read-only so name resolution works; a container mounting either path itself overrides that. - No overlayfs, privileged mode, cgroup limits, SELinux
relabeling, or NVIDIA CDI.
:z/:Zmount suffixes are accepted without relabeling. - Bind mounts hand containers host files, devices, and sockets with the invoking user's privileges; this is not a sandbox for untrusted code.
- Image ownership is normalized to the invoking user. Single-UID mapping
covers container UID/GID 0; other image users need subordinate mappings.
Under
--userns keep-id, container root is a subordinate ID and cannot write host files owned by you; run as your UID for that. Extended attributes, including file capabilities set withsetcap, do not survive into stored images, built or pulled. - Images and volumes held by containers refuse removal.
- The container lifecycle requires Linux with CGO; on other platforms —
and on Linux builds without CGO — image and store operations work and
lifecycle methods report
ErrUnsupported.