Skip to content

Containers

Containers package a node and all of its dependencies into a single, self-contained image. A containerized node runs identically regardless of what is installed on the host: no more “works on my machine” issues.

Use containers when you need:

  • Portability: ship a node to another machine without worrying about system dependencies.
  • Reproducibility: guarantee the same runtime environment every time.
  • Isolation: prevent conflicts between nodes that need different versions of the same library.

Peppy uses Apptainer as its container runtime. On macOS, Apptainer runs transparently inside a Lima virtual machine; no extra setup is needed.

On Linux, Apptainer uses unprivileged user namespaces which may require a one-time system configuration. The installer handles this automatically, but if you skipped it or installed Peppy manually you can run:

Terminal window
peppy container setup

This configures the following (prompting for sudo when needed):

  1. uidmap package: installs newuidmap (required for fakeroot mode).
  2. AppArmor profile (on systems that restrict unprivileged user namespaces via AppArmor): installs a profile that allows Apptainer to create user namespaces and loads it into the kernel. When the profile already exists but is not loaded, it is reloaded without being rewritten.

To check the current state without making any changes:

Terminal window
peppy container status

This prints a pass/fail summary of each prerequisite and exits with code 0 (all pass) or 1 (something needs fixing).

Pass the --container flag to peppy node init:

Terminal window
peppy node init --toolchain uv --container my_node

This generates the same project scaffolding as a regular node, plus an apptainer.def file that describes how the container image is built.

A container node includes a container block inside its execution section instead of the usual build_cmd and run_cmd fields. The two are mutually exclusive: a node is either a container node or a process node, never both.

peppy.json5
{
peppy_schema: "node/v1",
manifest: {
name: "my_node",
tag: "v1",
},
interfaces: {},
execution: {
language: "python",
container: {
def_file: "apptainer.def",
},
}
}

The def_file field points to the Apptainer definition file relative to the node root. You can rename or relocate it as long as def_file matches.

Compare this with a standard process node, which defines build_cmd and run_cmd instead:

peppy.json5 (process node)
{
peppy_schema: "node/v1",
manifest: {
name: "my_node",
tag: "v1",
},
interfaces: {},
execution: {
language: "python",
build_cmd: ["uv", "sync", "--no-editable"],
run_cmd: ["./.venv/bin/python", "-m", "my_node"]
}
}

Container nodes don’t need build_cmd or run_cmd; the definition file takes care of both building and running the node.

The generated definition file is a standard Apptainer definition file. Here is what peppy node init --container generates:

apptainer.def
Bootstrap: docker
From: peppybot/python-uv-base:latest
%labels
Name my_node
Version v1
%environment
export PATH="/opt/my_node/.venv/bin:$PATH"
%files
. /opt/my_node
%post
set -eux
export DEBIAN_FRONTEND=noninteractive
export UV_PYTHON_INSTALL_DIR=/opt/uv-python
cd /opt/my_node
uv python install
uv sync --no-editable
%runscript
cd /opt/my_node
exec ./.venv/bin/python -m my_node

Each section serves a specific purpose:

SectionPurpose
Bootstrap / FromBase image to build from (Ubuntu 24.04 by default)
%labelsMetadata embedded in the image
%environmentEnvironment variables set when the container runs
%filesCopies the node source into the image at /opt/<node_name>
%postBuild steps: install system packages, toolchains, and compile the node
%runscriptEntry point executed when the container starts

Adding a container node works the same as a regular node: first stage it, then build.

Terminal window
peppy node add ./my_node
peppy node build my_node:v1

You can also combine both steps with peppy node add ./my_node --build (shorthand -b). If you’ve just edited peppy.json5, add --sync/-s as well, e.g. peppy node add ./my_node -sb to sync, add, and build in one shot.

Under the hood, Peppy runs apptainer build during the build phase to produce a .sif (Singularity Image Format) file. This replaces the build_cmd step used by process nodes; the entire build happens inside the container according to the %post section of the definition file.

The resulting .sif file is stored under ~/.peppy/built_nodes/<name>_<tag>/, named after the fingerprint of the staged sources (see Build caching), and is ready to be started.

Starting a container node also uses the same command:

Terminal window
peppy node run my_node:v1

Peppy runs the .sif image with apptainer run and a clean environment: the daemon’s own environment is not inherited. The instance’s declared env_vars (minus HOME, which is dropped) and PEPPY_RUNTIME_CONFIG are passed in explicitly, and the image’s %environment section applies as usual.

The container executes the %runscript section, which runs the compiled binary (Rust) or the Python module entry point.

By default, a container is isolated from the host filesystem. Use mount_paths to bind-mount host directories into the running container, useful for sharing datasets, persisting output, or exposing device files.

Add a mount_paths array to the container block inside execution in peppy.json5:

peppy.json5
{
peppy_schema: "node/v1",
manifest: {
name: "my_node",
tag: "v1",
},
interfaces: {},
execution: {
language: "python",
container: {
def_file: "apptainer.def",
mount_paths: [
"/data/models:/opt/models:ro",
"/tmp/my_node_output:/output:rw"
]
},
}
}

Each entry follows the format host_path:container_path[:options]:

FormatExampleBehaviour
host_path"/data/models"Mounted at the same path inside the container
host_path:container_path"/data/models:/opt/models"Mounted at a different path inside the container
host_path:container_path:options"/data/models:/opt/models:ro"Mounted with explicit options (ro for read-only, rw for read-write)

host_path may start with ~, which expands to the home directory of the user running the daemon on the machine where the instance runs. In a federated launch, each machine expands ~ against its own home, so a single manifest works across machines with different usernames ("~/.cache/my_node:/cache:rw"). The ~user form is not supported and is rejected while the launch is being validated, before any stack is replaced. Only the host side is expanded: a ~ in container_path is passed to the container verbatim.

A source under /dev with no remap (no destination, or a destination equal to the source) is passed through rather than bound: host devices reach the container through Apptainer’s device mount, and only a remapped device bind emits an explicit --bind.

A missing mount source is created on the host as a directory before the container starts, and a warning names each auto-created path, so a typo in a file bind shows up as an unexpectedly created empty directory. Sources under the host runtime trees (/dev, /proc, /run, /sys) are used as-is and never created.

Mount paths can reference runtime parameters using the ${parameters:<path>} syntax. This lets each node instance mount a different host path based on its configuration.

peppy.json5
{
peppy_schema: "node/v1",
manifest: {
name: "uvc_camera",
tag: "v1",
},
interfaces: {},
execution: {
language: "rust",
parameters: {
device_path: "string",
},
container: {
def_file: "apptainer.def",
mount_paths: [
"${parameters:device_path}:/dev/video0:rw"
]
},
},
}

When the node runs, ${parameters:device_path} is replaced with the actual value provided in the deployment configuration. For example, if the instance supplies device_path: "/dev/video2", the resulting bind mount is /dev/video2:/dev/video0:rw.

For nested parameters, use dot notation:

peppy.json5 (nested example)
{
// ...
execution: {
// ...
parameters: {
video: {
device_path: "string",
frame_rate: "u16",
},
},
container: {
def_file: "apptainer.def",
mount_paths: [
"${parameters:video.device_path}:/dev/video0:rw"
]
},
},
// ...
}

Every peppy node build, including the ones peppy stack launch runs for each deployment, fingerprints the staged node tree before building: the node sources, peppy.json5, the def file, the generated .peppy/ code and the vendored peppylib, mixed with the peppy version, the host platform and the artifact kind. The artifact is stored under that fingerprint:

~/.peppy/built_nodes/<name>_<tag>/<fingerprint>.sif container node
~/.peppy/built_nodes/<name>_<tag>/<fingerprint>.tar.zst process node

When the fingerprint already has an artifact on disk, the build reuses it instead of running apptainer build (or build_cmd for a process node), and the build log carries one line:

Reusing cached build of my_node:v1 (fingerprint 3f9a1c2e5d7b8a4f) at ~/.peppy/built_nodes/my_node_v1/3f9a1c2e5d7b8a4f.sif

The cache is a plain directory tree keyed by content, so it survives daemon restarts and reboots, and a disk prepared on one machine carries its built nodes to every machine that boots from it with the same peppy version, OS and architecture. A process node’s archive also embeds what the host toolchain produced, which the fingerprint does not cover, so those machines need a compatible build environment as well. Each node identity keeps exactly one artifact: a build whose fingerprint differs replaces the previous one.

Pass --rebuild to peppy node build or peppy stack launch to build from the staged sources even when a matching artifact exists; the result replaces it. Use it when something outside the fingerprint changed:

  • A floating base image tag (From: peppybot/rust-cargo-base:latest) that now resolves to a different image. Pin the image with @sha256:<digest> in the def file to make base image changes part of the fingerprint.
  • For a process node, the host toolchain or the environment build_cmd runs with.

--rebuild is independent of --force, which cancels an in-flight build of the same node.

Rust container builds reuse a shared cache across builds, without any per-node configuration. The daemon bind mounts ~/.peppy/cache/container_build into the build at /peppy-cache and points the %post toolchain at it:

  • The crates.io registry persists via CARGO_HOME, so dependencies are downloaded once instead of on every build.
  • sccache caches rustc invocations via RUSTC_WRAPPER, so unchanged crates are not recompiled. The executable ships inside peppybot/rust-cargo-base, so nothing needs to be installed on the host, on Linux or macOS; the wrapper is activated only for defs that bootstrap from that image.

Caching is best effort: if any part of it is unavailable, the build runs exactly as it would without caching. Set PEPPY_NO_CONTAINER_BUILD_CACHE=1 in the daemon’s environment to disable caching entirely. When the def file mentions rustup (installing it in %post is incompatible with the CARGO_HOME override) or sets one of the managed variables itself, caching is skipped automatically for that build.

You can pass additional command-line arguments directly to Apptainer or Lima using apptainer_build_extra_args, apptainer_run_extra_args, and lima_shell_extra_args in the container block inside execution.

peppy.json5
{
peppy_schema: "node/v1",
manifest: {
name: "my_node",
tag: "v1",
},
interfaces: {},
execution: {
language: "python",
container: {
def_file: "apptainer.def",
apptainer_build_extra_args: ["--no-setgroups"],
apptainer_run_extra_args: ["--no-setgroups"],
},
}
}
FieldPurpose
apptainer_build_extra_argsExtra flags appended to apptainer build (e.g., ["--no-setgroups"])
apptainer_run_extra_argsExtra flags appended to apptainer run (e.g., ["--no-setgroups"])
lima_shell_extra_argsExtra flags passed to limactl shell on macOS (ignored on Linux)

All fields are optional and default to an empty list when omitted.

On macOS, Apptainer is not natively available. Peppy bundles a Lima virtual machine that runs Apptainer inside a lightweight Linux guest. This is handled transparently and requires no additional installation or configuration; peppy node commands work the same way on macOS and Linux, with one exception: mount sources under the host runtime trees (/dev, /proc, /run, /sys) are refused, since a device plugged into the Mac cannot be forwarded into the Linux guest.

The generated apptainer.def is a starting point. You can modify it freely to fit your needs. Common customizations include:

Add packages to the %post section:

%post
apt-get update
apt-get install -y --no-install-recommends \
libopencv-dev libudev-dev
rm -rf /var/lib/apt/lists/*

Swap the From line to use a different base:

Bootstrap: docker
From: nvidia/cuda:12.4.0-devel-ubuntu24.04

Using a pre-built base image for faster builds

Section titled “Using a pre-built base image for faster builds”

Every peppy node add runs the full %post section from scratch, installing system packages, toolchains, and compiling dependencies each time. For nodes with heavy dependencies this can be slow.

You can speed things up by baking those slow steps into a custom Docker image and using it as your base. The first build pays the cost once; every subsequent node add starts from the cached image and only rebuilds your application code.

  1. Create a Dockerfile with the dependencies your node needs:

    Dockerfile
    FROM ubuntu:24.04
    RUN set -eux \
    && export DEBIAN_FRONTEND=noninteractive \
    && apt-get update \
    && apt-get install -y --no-install-recommends \
    ca-certificates curl python3 python3-venv \
    && rm -rf /var/lib/apt/lists/* \
    && curl -LsSf https://astral.sh/uv/install.sh | sh
  2. Build and push the image to a registry your build machine can reach:

    Terminal window
    docker build -t my-registry/my_node-base:latest .
    docker push my-registry/my_node-base:latest
  3. Point your apptainer.def at the new image and remove the steps that are already baked in:

    apptainer.def
    Bootstrap: docker
    From: my-registry/my_node-base:latest
    %labels
    Name my_node
    Version v1
    %environment
    export PATH="/root/.local/bin:/opt/my_node/.venv/bin:$PATH"
    %files
    . /opt/my_node
    %post
    set -eux
    cd /opt/my_node
    uv sync --no-editable
    %runscript
    cd /opt/my_node
    exec ./.venv/bin/python -m my_node

Now peppy node add only runs the application-specific build steps; package installation and toolchain setup are already in the base image.

Add variables to the %environment section so they are available at runtime:

%environment
export PATH="/opt/my_node/.venv/bin:$PATH"
export PYTHONUNBUFFERED=1