Image Variants¶
Shed images are OCI-compliant container images. The on-disk store is an OCI image-layout-v1 directory, layers are shared across tags, and pulls/pushes go directly to any OCI registry — no Docker daemon required for transport.
Each image carries:
- one or more read-only gzipped tar layers (Dockerfile-stage shape preserved),
- a prebuilt rootfs erofs blob (
io.shed.rootfs.erofs.digestannotation; this is what the VM mounts read-only at/dev/vdb), - a kernel blob (
io.shed.kernel.digest), - an initrd blob (
io.shed.initrd.digest), - standard annotations (source ref, variant name, shed schema version).
The rootfs erofs is minted at image-publish time by mkfs.erofs running
inside the shed-build-tools container — a known-good
erofs-utils version is pinned there so every consumer sees byte-identical
filesystem layout regardless of where the image was published or where
it'll be mounted. Hosts do not invoke mkfs.erofs themselves; they
download the blob and mount it directly. At boot the in-guest initramfs
assembles a single-lower overlayfs with the per-shed writable upper on
top.
Prerequisites¶
No host-side erofs tooling is required. The shed-server package does
not depend on erofs-utils; the OCI manifests ship the prebuilt erofs
as a content-addressed blob. (Hosts that publish images — i.e., run
shed image build — need Docker so the build-tools container can run,
but not erofs-utils directly on the host.)
If you're upgrading from v0.5.1 or earlier, see the
v0.5.1 → v0.5.2 upgrade guide for the
breaking-change details and the required shed image rm / pull-images
steps.
Available Variants¶
| Variant | Description |
|---|---|
base |
Minimal OS layer: systemd, SSH, Docker CE, git, gh, build-essential, the shed-agent. No coding agents, no language runtimes. |
extensions |
base + shed-extensions credential brokering (SSH agent forwarding, AWS STS proxy, Docker credential helper). No coding agents pinned — start here for custom images. |
full |
extensions + Node.js LTS, Python 3.13, Claude Code, OpenCode, Codex CLI, and (VZ only) Cursor CLI. The batteries-included default. |
Each variant inherits from the one above it as a discrete OCI layer, so
shed image pull shed-vz-full reuses the base and extensions layers
already cached locally.
extensions variant¶
The extensions variant adds shed-extensions credential brokering on top of base. It includes:
shed-ssh-agent— SSH agent proxy that forwards key operations to your Mac (private keys never enter the VM).shed-aws-proxy— AWS credential proxy that vends short-lived STS tokens via the host.docker-credential-shed— Docker credential helper that delegates registry authentication to the host. Guest Docker is pre-configured with{"credsStore": "shed"}.shed-ext— CLI for checking extension connectivity and health.- Pre-configured
SSH_AUTH_SOCKandAWS_CONTAINER_CREDENTIALS_FULL_URIenvironment variables.
When to use it: as a base for organization-specific images, or when you want credential brokering without the full coding-agent set.
Prerequisite: the shed-host-agent binary must be running on your host.
See the shed-extensions quick start.
Published Images¶
Pre-built images for each variant are published per release to ghcr.io:
| Image | Platform | Tag Format |
|---|---|---|
ghcr.io/charliek/shed-vz-base |
linux/arm64 | :v{version} |
ghcr.io/charliek/shed-vz-extensions |
linux/arm64 | :v{version} |
ghcr.io/charliek/shed-vz-full |
linux/arm64 | :v{version} |
ghcr.io/charliek/shed-fc-base |
linux/amd64 | :v{version} |
ghcr.io/charliek/shed-fc-extensions |
linux/amd64 | :v{version} |
ghcr.io/charliek/shed-fc-full |
linux/amd64 | :v{version} |
Replace {version} with the version matching your shed binary — run
shed version to check.
Both VZ and Firecracker images embed the kernel needed to boot the VM. For
VZ, the kernel and initrd are extracted from the Ubuntu
linux-image-generic package. For Firecracker, a custom kernel is compiled
with Docker, 9P, and BPF support built in.
To pre-cache images:
Layer Model¶
The on-disk store under {images_dir}/ is OCI image-layout-v1:
{images_dir}/
oci-layout # {"imageLayoutVersion":"1.0.0"}
index.json # OCI image index
blobs/sha256/<hex> # OCI blobs (manifests, configs, layer tar.gz, kernel, initrd, rootfs erofs)
tags/<name>.json # tag → manifest digest pointers
uppers/<shed>/upper.ext4 # per-shed writable overlay upper
instances/<shed>/metadata.json # per-shed bookkeeping (pins manifest digest)
snapshots/<snap>/snapshot.json # per-snapshot bookkeeping
Everything is a content-addressed blob. Layer tarballs, kernel,
initrd, and the prebuilt rootfs erofs all live in blobs/sha256/ keyed
by their content sha256. The rootfs erofs is what the VM mounts at boot
as /dev/vdb; the layer tarballs sit alongside it so shed image push
can byte-perfectly round-trip the manifest to a registry and so derived
images (FROM chains) can reuse the lower-numbered layers without
re-downloading them.
The legacy cache/sha256/<manifest-digest>.erofs directory (used
through v0.5.1 for locally-materialized erofs files) is gone. Older
installs may still have one — it's safe to rm -rf after upgrading;
see the v0.5.1 → v0.5.2 upgrade guide.
Layer sharing happens at the blob layer. Two tags that share a base
layer share the underlying blob — pulling shed-vz-full:v0.5.1 after
shed-vz-extensions:v0.5.1 only fetches the top layers from the
registry. The flattened erofs is per-manifest, so each variant has its
own erofs file (no cross-variant sharing for the boot artifact), but the
big shared layers underneath cost once across all variants.
Disk overhead. Each manifest's flattened erofs file is roughly 0.5–0.7× the equivalent uncompressed ext4 (lz4 compression). Total cost for an image is the sum of its layer tar.gz blobs (deduplicated across manifests) plus its flattened erofs (per-manifest). See Layer storage optimization for design notes.
Layer cap. MaxLayers = 16. Manifests with more than 16 layers are
rejected at pull/load time. Shed's own variants ship 5–10 layers (base
sits near the low end, full near the high end). With the flatten model
the cap is a soft hint — only the blob deduplication math cares about
layer count at boot time, not overlayfs assembly.
Inspecting layers with shed image history¶
shed image history <tag> walks the manifest top-down (latest layer first)
and prints one row per layer. The output describes the manifest's layer
chain — useful for understanding what blobs the image references and how
much each Dockerfile instruction contributes to the size — but does NOT
correspond to a guest-side overlay stack. The guest sees a single
flattened lower regardless of the number of layers.
LAYER DIGEST SIZE CREATED CREATED BY
9 sha256:6214c050b2d46d711a9878da53f2ae1f1c2cc2644d1d30f9116d346c59d06ab2 493.4 MB 2 hours ago RUN runuser -l shed -c '… mise use -g node@lts; uv python install 3.13; …'
8 sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 32 B 2 hours ago ENV CLAUDE_CONFIG_DIR=/home/shed/.claude
7 sha256:5c61939d1edf11daa570fcfe8ea24b56a60a89403f5ce91c4354cd400cad2591 6.92 MB 2 hours ago RUN --mount=type=bind,from=shed-extensions … (extensions binaries)
…
2 sha256:a3e89a578b079f684c28e09084737b3ff22914ab234c60ae0064c6f4d218be54 1.18 GB 2 hours ago RUN apt-get install systemd docker-ce …
1 sha256:818154cda96df8bbb276b4f4339124da55756620a1037af15570bc95312850fa 28 MB 2 hours ago ubuntu:24.04 base
The CREATED BY column is the corresponding history entry from the OCI
config — most often the Dockerfile line that produced the layer. The
big shared layers — ubuntu:24.04 (ordinal 1) and the APT install
(ordinal 2) — appear with the same digest in base, extensions, and
full, so the underlying tar.gz blobs cost once across all three
variants.
Air-gap transport with shed image save / shed image load¶
shed image save writes a tag (and every layer it references) to a
single OCI archive file, suitable for transferring across an air-gap or
keeping as a backup:
The archive is a standard OCI image layout — third-party tools work on it
unmodified. For example, crane manifest --from-archive can pull the
manifest out without needing a registry:
shed image load is the inverse:
It unpacks each blob into the local store and advances the tag(s).
The rootfs erofs blob ships in the OCI archive alongside the layers —
no on-host mkfs.erofs step is needed. Layers already present
locally are skipped.
Push to a registry¶
shed image push uploads a tag or digest to any OCI registry:
shed image push shed-vz-full ghcr.io/myorg/shed-vz-full:v0.5.1
shed image push sha256:9a1c... ghcr.io/myorg/shed-vz-full@sha256:9a1c...
The upload is byte-perfect: the manifest digest at the destination equals
the manifest digest in the local store. That means a tag pushed from one
host and pulled by another resolves to the same digest, and shed image
inspect shows identical manifest annotations on both sides.
Registry authentication uses your Docker credential helper (e.g. the
shed-extensions Docker credential proxy when the extensions variant is
running). For host-side shed image push invocations, the regular
~/.docker/config.json credential resolution applies.
Manifest annotations¶
Every shed-built manifest carries these annotations (visible via
shed image inspect):
| Annotation | Description |
|---|---|
io.shed.variant |
One of base, extensions, full, or a custom string for derivations. |
io.shed.source-ref |
The Docker / OCI reference the image was pulled or built from. |
io.shed.kernel.digest |
Digest of the kernel blob embedded in the image. |
io.shed.initrd.digest |
Digest of the initrd (shed-built initramfs) blob. |
io.shed.rootfs.erofs.digest |
Digest of the prebuilt read-only rootfs erofs blob the VM mounts at /dev/vdb. v0.5.2+. Required — images missing this annotation are rejected at boot. |
io.shed.schema-version |
Metadata schema version (currently v3). |
io.shed.rootfs.logical-size |
Sum of layer logical sizes — what the merged overlay sees from inside the guest. |
Annotations are preserved across pull / save / load / push and
match byte-for-byte across hosts.
Boot stack¶
When shed start runs, the in-guest initramfs:
- Mounts each layer's ext4 read-only at
/lower/<n>. - Mounts
/dev/vdb(the per-shedupper.ext4) read-write at/upper. - Mounts an overlayfs at
/sysrootwith lowerslower/1:lower/2:…:lower/N(top layer last) and upper/upper. switch_rootinto/sysroot.
Panic messages from the initramfs are numbered for triage:
| Code | Meaning |
|---|---|
SHED-INIT-02 |
Kernel cmdline missing a required shed.lower.N= directive. |
SHED-INIT-03 |
A layer device referenced by cmdline is absent. |
SHED-INIT-04 |
Layer ext4 superblock check failed (wrong magic at offset 1080). |
SHED-INIT-05 |
Upper ext4 superblock check failed — typically the upper is uninitialized; recoverable with shed reset <name>. |
SHED-INIT-06 |
overlayfs mount returned -EINVAL; usually a kernel-without-overlay regression. |
SHED-INIT-07 |
Could not pivot into /sysroot. |
SHED-INIT-08 |
More than MaxLayers (16) layers declared. |
SHED-INIT-09 |
Schema-version mismatch — the running initramfs expects metadata v3, the image declares something else. |
shed reset <name> recovers from upper-corruption panics
(SHED-INIT-05).
Server Configuration¶
Using published images (recommended)¶
Point your config at OCI references. Shed pulls them registry-direct on
first shed create (no Docker daemon needed for pull):
Using local images¶
If you build images locally, point to OCI references in a local registry or to tags already present in the blob store:
You can mix registry refs and local tags in the same config.
The base_rootfs field is the default when no --image flag is passed
to shed create. The images map enables per-shed variant selection.
images_dir is the OCI store root.
base_rootfs vs images:¶
images:is a map of named variants. Each entry is selectable viashed create --image <name>, pre-pullable viashed-server pull-images, and visible inshed image ls.base_rootfsis the fallback forshed createinvocations without--image. It's stored as the underscore-prefixed tag_baseso it doesn't collide with user-facing variant names.
When base_rootfs matches an images: entry, the underlying manifest
digest is shared — _base and the variant tag point at the same OCI
manifest, and pulls deduplicate to a single layer set.
Using Variants¶
shed create myproject --image full # batteries-included
shed create myproject --image extensions # credential brokering, no agents
shed create tools --image base # minimal
Default (no --image flag) uses base_rootfs:
List local images:
Creating Custom Images¶
The easiest path is a Dockerfile that extends a published variant. Most
custom images start from extensions — you get credential brokering for
free, no coding agents are pre-pinned, and your layer sits on top of a
stable shed-managed base.
FROM ghcr.io/charliek/shed-vz-extensions:v{version}
USER shed
ENV PATH="/home/shed/.local/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/home/shed/.cargo/bin:${PATH}"
RUN curl -fsSL https://claude.ai/install.sh | bash
USER root
WORKDIR /workspace
ENTRYPOINT ["/sbin/init"]
Constraints when extending a shed image:
- Keep
ENTRYPOINT ["/sbin/init"]— shed boots via systemd. - Don't remove or reassign the
sheduser (UID 1000); much of the boot setup assumes it. - Don't disable the
shed-agent.serviceunit.
Build for the right backend platform (linux/arm64 for VZ,
linux/amd64 for Firecracker):
For an end-to-end worked example including push to a private registry, see Build your own image.
Builder backend¶
shed image build's default Dockerfile path shells out to
docker buildx. To build without a Docker daemon, produce an OCI
image-layout tar with any tool (podman, buildah, nix-build, etc.) and
pass it to shed image build --from-oci-archive <path> — the
downstream ingestion (layer install, manifest annotations, initramfs
injection, tag advancement) is pure Go. See
Build your own image § 2a
for the workflow.
shed image pull is already docker-free — it uses
go-containerregistry
directly.
Image Caching¶
Pull / save / load / build all land manifests and blobs in the OCI
store. Tag pointers (tags/<name>.json) are the human-readable handles.
When the OCI ref in your config changes (for example after a version
bump), shed compares the manifest's recorded io.shed.source-ref
annotation against the configured ref. On mismatch, the cache is
considered stale and shed-server pull-images re-pulls.
Cleaning Up Images¶
Shed follows the Docker model: shed image rm removes a tag,
shed image prune garbage-collects unreferenced blobs.
shed image rm myimage # removes the tag; blobs persist
shed image prune --dry-run # preview reclaim
shed image prune # reclaim
shed image prune walks every shed and snapshot, collects the manifest
digests they pin (instances/<name>/metadata.json → lower_digest),
expands each manifest to the layers it references, and deletes any blob
or cached ext4 not in that reachable set. Tags do NOT protect blobs.
Deleting a tag for an image that's still pinned by a running or stopped shed is safe — the shed boots from the digest pinned in its metadata, not the tag.
Cookbook: upgrading image versions¶
- Delete any sheds you no longer need.
- Bump the refs in
server.yaml. -
Restart the server.
-
Pull the new images.
- Reclaim stale blobs.
- Verify.
Upgrading across schema versions¶
Metadata schema went v2 → v3 with the OCI store rollout. The
initramfs refuses to mount lower images stamped with anything other than
v3. Pre-v3 sheds and snapshots are not migrated automatically.
To upgrade from a v2 install:
- Stop the server.
- Delete
images_dir,instances/,snapshots/, anduppers/. - Install the new release.
- Restart the server and re-pull images.
Workspace data under --local-dir mounts is unaffected. Workspace data
that lived only inside the deleted upper layers is lost, by design — the
upper layer is per-shed scratch space, not durable storage.
Disk Space¶
Per-shed cost is the writable upper layer alone (sparse, default 5 GB,
configurable via shed create --upper-size) — not a copy of the rootfs.
All read-only layers are shared across every shed pinning the same
manifest digest, both on disk and in the host page cache.
To measure usage:
See Disk Management and Storage Model for layout details and reclamation.