Layer-efficient Docker image transfer to an embedded Linux device (Raspberry Pi, Jetson, etc.) over a direct cable connection.
Instead of docker save | ssh | docker load — which always transfers the full image — relay push starts a Registry v2 server on the laptop, opens a reverse SSH tunnel so the device's localhost reaches it, then triggers docker pull on the device. Docker only fetches layers it doesn't already have.
Mac Device (Pi / Jetson)
──────────────────── ──────────────────────
relay serve SSH reverse tunnel
(port 5050) ←────── localhost:5050
docker pull localhost:5050/<image>
The reverse tunnel means all traffic flows through the existing SSH connection — no firewall rules or insecure-registry config needed on the Mac side. The device needs localhost:5050 in its insecure-registries (see Setup).
ContainerdStore (used when available): reads original compressed blobs directly from Docker's containerd content store at /var/lib/docker/containerd/daemon/io.containerd.content.v1.content/blobs/sha256/. Because these are the original bytes from the registry, digests match Docker Hub exactly — layers the device already has from any previous pull are cache-hits.
DockerSaveStore (fallback): streams the image tar via the Docker Engine API, extracts and caches layer blobs on disk. Requires the containerd image store to be enabled in Docker so that docker save outputs OCI-format blobs (see "Enabling the containerd image store" below). This is the path used on macOS where the containerd store directory is inside Docker's VM.
- Mac: Odin compiler, Docker, SSH key access to the device
- Device: Docker, Odin compiler (for
make deploy)
# Build relay for macOS
make build
# Sync source to the device and build there
# Edit DEVICE_USER / DEVICE_IP in Makefile first
make deployTo install Odin on the device (one-time):
curl -L "https://github.com/odin-lang/Odin/releases/download/dev-2026-05/odin-linux-arm64-dev-2026-05.tar.gz" \
| tar -xz && sudo mv odin-linux-arm64-*/odin /usr/local/bin/Docker requires an explicit host:port entry in insecure-registries to use HTTP for that registry. Run this once on the device:
echo '{"insecure-registries":["localhost:5050","127.0.0.1:5050"]}' \
| sudo tee /etc/docker/daemon.json
sudo systemctl restart docker# On the Mac — transfers only missing layers
./relay push dustynv/ros:humble-ros-base-l4t-r35.4.1 --to pi@192.168.1.25The command prepares the image cache, starts a local registry, opens the SSH tunnel, then triggers docker pull on the device. Docker's own layer deduplication skips anything the device already has.
relay push <image> --to <user@host> [--port 5050]
Push an image to a device. Handles everything: cache prep, server, SSH tunnel, remote pull.
relay serve [--port 5050] [--cache-dir ~/.cache/relay]
Start the Registry v2 server in standalone mode (for use with pull or manual docker pull).
relay pull <image> --from <host:port>
Run on the device. Prints insecure-registry hints and pulls from the given registry.
relay list
List images available in the local Docker daemon.
ContainerdStore gives better cache hit rates because layer digests match Docker Hub exactly. On Docker Desktop (macOS): Settings → Features in development → Use containerd for pulling and storing images. On Docker Engine (Linux):
{ "features": { "containerd-snapshotter": true } }odin-docker-local-registry/
├── registry/ # package registry — core library
│ ├── server.odin # Registry v2 HTTP handlers (odin-http)
│ ├── store.odin # Store abstraction + helpers
│ ├── containerd_store.odin
│ ├── dockersave_store.odin
│ ├── manifest.odin # OCI manifest types + builder
│ ├── tar.odin # Streaming tar reader
│ ├── docker_cli.odin # Docker Engine API wrappers
│ └── docker_socket.odin # Minimal HTTP client over Unix socket
├── tests/
├── main.odin # CLI entry point
├── vendor/odin-http/
└── Makefile