Build Your Own Image¶
This tutorial walks through deriving a custom shed image from the
published extensions variant, pushing it to a private registry, and
booting a shed from it.
We'll build an image with uv (Python package manager), Python 3.13, and OpenCode preinstalled.
Why start from extensions?¶
The three published variants are:
| Variant | Contents | Recommended use |
|---|---|---|
base |
OS layer only. systemd, SSH, Docker, the shed-agent. | Tiny images. You'll wire up coding agents and credentials yourself. |
extensions |
base + shed-extensions credential brokering. No coding agents pinned. |
Custom images. Credentials work out of the box; you choose the runtime/agent set. |
full |
extensions + Node.js LTS, Python 3.13, Claude Code, OpenCode, Codex CLI. |
Default for shed create. |
extensions is the right starting point because:
- Credential brokering is preconfigured — SSH agent forwarding, AWS STS, and Docker registry auth work without further setup.
- No coding agents are pinned, so you choose versions yourself without
fighting
full's defaults. - You get layer sharing with
extensionsandfullfor free: anyone who's already pulled either variant only fetches the top layer of your derived image.
Prerequisites¶
- A host with Docker installed (for
docker buildx). shedandshed-serveralready set up. See VZ Setup or Firecracker Setup.- A private registry you can push to. We'll use
ghcr.io/myorg/my-shed-image:v1in examples — substitute your own.
1. Write the Dockerfile¶
Save the following as Dockerfile.shed in an empty directory:
# Pick the variant matching your backend platform.
# VZ (macOS arm64): shed-vz-extensions
# Firecracker (Linux amd64): shed-fc-extensions
ARG SHED_VERSION=v0.5.1
FROM ghcr.io/charliek/shed-vz-extensions:${SHED_VERSION}
USER root
# Install uv (system-wide, statically linked).
RUN curl -fsSL https://astral.sh/uv/install.sh \
| env UV_INSTALL_DIR=/usr/local/bin sh
# Install Python 3.13 via uv (system-wide).
RUN /usr/local/bin/uv python install 3.13 --default --preview
# Install OpenCode as the shed user (writes to /home/shed/.local).
USER shed
RUN curl -fsSL https://opencode.ai/install | bash
ENV PATH="/home/shed/.local/bin:${PATH}"
# Tag the derived layer so it shows up in `shed image inspect`.
LABEL io.shed.variant=myorg-python-opencode
# Mandatory shed boot setup — keep these.
USER root
WORKDIR /workspace
ENTRYPOINT ["/sbin/init"]
Notes on the constraints:
FROM ghcr.io/charliek/shed-vz-extensions(orshed-fc-extensionsfor Firecracker) — the layer below yours must be a shed-managed variant. Other base images will not boot.- Do not remove the
sheduser (UID 1000) — much of the shed-agent and credential brokering assumes it exists. - Do not disable
shed-agent.service— it's how the host talks to the guest. ENTRYPOINT ["/sbin/init"]is mandatory. Shed boots via systemd.
2. Build the image¶
Use docker buildx with the right platform for your backend
(linux/arm64 for VZ, linux/amd64 for Firecracker):
# VZ (macOS arm64)
docker buildx build \
--platform linux/arm64 \
-t ghcr.io/myorg/my-shed-image:v1 \
-f Dockerfile.shed \
--load \
.
# Firecracker (Linux amd64)
docker buildx build \
--platform linux/amd64 \
-t ghcr.io/myorg/my-shed-image:v1 \
-f Dockerfile.shed \
--load \
.
--load makes the image available to the local Docker daemon so the
next step can push it.
2a. Alternative: building without Docker¶
shed image build drives docker buildx under the hood. If you don't
want a Docker daemon on your build host, any tool that emits an OCI
image-layout tar will work — shed ingests it via
shed image build --from-oci-archive. The downstream pipeline
(layer ingestion into the blob store, initramfs annotation, source-ref
annotation) is pure Go and runs identically regardless of which tool
produced the archive.
Build with podman (rootless on Linux, no daemon):
# Linux/amd64 → Firecracker; macOS/arm64 → VZ
podman build \
--platform linux/arm64 \
--output type=oci,dest=my-shed-image.tar \
-f Dockerfile.shed \
.
Build with buildah (rootless on Linux, no daemon, no Podman):
buildah bud \
--platform linux/arm64 \
--output type=oci-archive,dest=my-shed-image.tar \
-f Dockerfile.shed \
.
Anything else that emits an OCI archive works too — nix-build
piped through skopeo, a custom Go binary using
github.com/google/go-containerregistry, even hand-assembled tarballs
that conform to OCI image-layout-v1.
Then ingest into shed's local store:
# Build the shed-overlay initramfs (image-content-independent — one per
# machine suffices, the same artifact works for every derived image).
./scripts/build-initramfs.sh --backend vz --platform linux/arm64 --output /tmp/shed-initrd.img
# Ingest the OCI archive. shed handles the initramfs blob injection,
# manifest annotations, source-ref, kernel extraction, and tag
# advancement — same as the docker path, just without docker.
shed image build \
--from-oci-archive my-shed-image.tar \
-n my-shed-image \
--initramfs /tmp/shed-initrd.img \
--source-ref ghcr.io/myorg/my-shed-image:v1
--from-oci-archive is mutually exclusive with --file, --target,
--platform, and --force (those are Dockerfile-mode options;
ingesting a pre-built archive has nothing to drive). --initramfs,
--name, --source-ref, and --output-dir apply to both modes.
From here the rest of the flow (push, pull, boot) is identical to the Docker path below.
What still needs Docker. Today shed-server itself doesn't need
Docker for anything in the boot/image lifecycle: pull, push,
save, load, inspect, history, ls, prune, the
materialize step on shed create, snapshots, and all VM ops are
Docker-free. The remaining Docker dep is only shed image build
when invoked without --from-oci-archive. With this flag, a
cloud-VPS or rootless-Linux host can run shed-server end-to-end
without installing Docker CE.
3. Push to a private registry¶
You have two options.
Option A — docker push (uses your existing Docker credentials):
Option B — shed image push (registry-direct, byte-perfect):
# Build the shed-overlay initramfs (shared across variants — once
# per machine is enough, the artifact is image-content-independent).
./scripts/build-initramfs.sh --backend vz --platform linux/arm64 --output /tmp/shed-initrd.img
# Import the just-built rootfs into shed's local OCI store.
shed image build \
-f Dockerfile.shed \
-n my-shed-image \
--initramfs /tmp/shed-initrd.img \
.
# Then push from there. --local skips the HTTP API hop to a running
# shed-server, which is handy from a build host without one running.
shed image push --local my-shed-image ghcr.io/myorg/my-shed-image:v1
shed image push is byte-perfect: the manifest digest at the
destination equals the local manifest digest, so any host that later
pulls ghcr.io/myorg/my-shed-image:v1 resolves to the same digest you
just pushed. The shed-overlay initramfs is uploaded as a sibling blob
referenced by an io.shed.initrd.digest annotation, so the receiving
host doesn't need to rebuild it locally.
4. Pull on the shed server¶
On any machine running shed-server:
shed image pull is registry-direct — no Docker daemon required on
the shed server. Authentication uses
~/.docker/config.json and any installed credential helpers (including
the shed-extensions Docker credential proxy when an extensions-based
shed is running).
Verify the layer stack:
You should see roughly 9–11 layers: your RUN curl … uv …,
RUN curl … opencode …, the LABEL and any other top-of-Dockerfile
instructions you added, then the 7-or-so layers from the parent
extensions variant. The big shared layers (ubuntu:24.04,
apt-get install …) appear with the same digest the parent uses, so on
disk you only pay for the bytes your top-of-Dockerfile additions
brought in.
5. Boot a shed from it¶
Inside the shed:
6. (Optional) Make it your default¶
To make shed create (without --image) use your derived image by
default, edit your server config:
vz:
base_rootfs: my-image # or the full ghcr.io ref
images:
base: ghcr.io/charliek/shed-vz-base:v0.5.1
extensions: ghcr.io/charliek/shed-vz-extensions:v0.5.1
full: ghcr.io/charliek/shed-vz-full:v0.5.1
my-image: ghcr.io/myorg/my-shed-image:v1
Restart the server. Subsequent shed create <name> calls without
--image will boot from your custom layer set.
Iterating¶
To rebuild after editing the Dockerfile:
docker buildx build --platform linux/arm64 -t ghcr.io/myorg/my-shed-image:v2 -f Dockerfile.shed --load .
docker push ghcr.io/myorg/my-shed-image:v2
shed image pull ghcr.io/myorg/my-shed-image:v2 -t my-image
Existing sheds keep booting from the digest they were created against —
tag updates don't propagate to running sheds (see
Storage Model → Tag updates).
To roll a shed onto the new image, shed delete devbox and recreate.
See also¶
- Image Variants — the full variant lineup, annotations, and the boot stack.
- Storage Model — how the OCI store is laid out on disk.
- CLI Reference — every
shed imagesubcommand.