OutpostImage turns every edge node into a zero-overhead OCI registry by reusing the local Docker/containerd image store — enabling node-to-node image sharing at LAN speed, without extra storage.
OutpostImage runs as a transparent HTTP proxy on every edge node.
OutpostImage intercepts image pulls, resolves from peers first, and shares back to the LAN — in three phases.
Phase 1 — Intercept: Docker or containerd is configured with a single env var: HTTP_PROXY=http://localhost:3128. All registry traffic flows through OutpostImage automatically — no mirror config, no daemon changes. For HTTPS registries, a locally-generated CA certificate enables transparent inspection.
Phase 2 — Resolve: For every blob or manifest, OutpostImage checks the local image store first, then queries P2P peers on the LAN via /api/v1/digests/{digest}. Only on a complete miss does it fall back to the upstream registry.
Phase 3 — Share: Once a node has an image, it becomes a source for every other node. Each instance exposes a standard OCI registry API on :5000, serving blobs directly from the local store. Peers discover each other via static config or mDNS auto-discovery — no central coordinator needed.
Built for edge deployments where bandwidth and storage are constrained.
Works with both Docker and containerd via HTTP proxy — no daemon config changes required.
Reuses the existing Docker or containerd image store. Blobs are never duplicated on disk.
Static peer lists and mDNS auto-discovery build a LAN-speed mesh across all edge nodes.
A single HTTP_PROXY env var is all you need. Zero-intrusion deployment.
Built-in metrics endpoint at :9090 plus /healthz and /readyz probes.
Fully compliant OCI registry API at :5000 — compatible with any OCI-conformant client.
Works with Docker Hub, GHCR, ECR, and any other OCI-compatible registry simultaneously.
All options available as CLI flags or environment variables. Hot-reloading peers file supported.
Both solve P2P image distribution — OutpostImage targets broader runtime compatibility.
| Feature | Spegel | OutpostImage |
|---|---|---|
| Runtime support | containerd only | Docker + containerd |
| Interception method | containerd mirror config | HTTP_PROXY env var |
| Extra storage | None (containerd reuse) | None (Docker/containerd reuse) |
| P2P discovery | OCI / libp2p | Static peers + mDNS |
| Multi-registry | Yes | Yes |
| Invasiveness | Modifies containerd config | Zero-intrusion (env var only) |
Real-world validation across Docker and containerd on edge VMs (2026-03-30). All 5 value propositions passed. Full reports in docs/.
Two Ubuntu 20.04 KVM virtual machines on a Proxmox host, connected over a LAN bridge.
| Node | IP | Role | OS | Docker | containerd | Hardware |
|---|---|---|---|---|---|---|
| VM 102 | 192.168.1.102 | Image source + OutpostImage server | Ubuntu 20.04.6 LTS | 26.1.3 | 1.7.2 | QEMU 1 vCPU, 2 GB RAM |
| VM 103 | 192.168.1.103 | Image consumer | Ubuntu 20.04.6 LTS | 26.1.3 | 1.7.2 | QEMU 1 vCPU, 2 GB RAM |
OutpostImage configuration: Registry :15000, HTTP Proxy :13128, Metrics :19090. Raw TCP bandwidth between nodes: 5.4 Gbps.
| Value Proposition | What Was Tested | Evidence | Result |
|---|---|---|---|
| VP-1 Zero-intrusion setup | Single HTTP_PROXY env var, no daemon.json or containerd config changes |
All requests served locally; zero upstream traffic logged | PASS |
| VP-2 Multi-registry transparent interception | Pull from docker.io, ghcr.io, and registry.example.com in one session | 3 registries intercepted; X-Original-Registry header routed correctly |
PASS |
| VP-3 Docker + containerd dual runtime | 5 Store×Consumer combinations (docker/containerd/auto × docker pull / ctr pull) | All 5 combinations pulled and unpacked successfully | PASS |
| VP-4 Minimal dependencies | Count direct deps, total transitive deps, binary size; compare vs Spegel | 7 direct / 56 total deps; 11 MB static binary; ldd: not a dynamic executable |
PASS |
| VP-5 Offline-first | Full network isolation via iptables DROP; pull images VM 102 → VM 103 |
Zero upstream registry references; containers ran successfully offline | PASS |
Configure Docker with a single proxy env var — no mirror config, no daemon changes needed.
# /etc/systemd/system/docker.service.d/proxy.conf
[Service]
Environment="HTTP_PROXY=http://192.168.1.102:13128"
Environment="HTTPS_PROXY=http://192.168.1.102:13128"
After restart, pulling redis:7-alpine and debian:bookworm-slim — all traffic served locally:
v2 version check served locally source=local path=/v2/
manifest served from local source=local path=/v2/library/redis/manifests/7-alpine
routing blob to local (direct) source=local path=/v2/library/redis/blobs/sha256:34c35a...
routing blob to local (direct) source=local path=/v2/library/debian/blobs/sha256:5712eb...
# All 8 layers + config blobs served locally — zero upstream requests
Three different registries pulled in a single session — no per-registry configuration required.
| Image Pulled | Registry | Image ID | Size | Result |
|---|---|---|---|---|
ghcr.io/test/debian:bookworm-slim |
GitHub Container Registry | d1c8cb627495 | 78.6 MB | PASS |
registry.example.com/test/redis:7-alpine |
Custom registry | e053f0daf05a | 41.7 MB | PASS |
redis:7-alpine |
Docker Hub | e053f0daf05a | 41.7 MB (layers cached) | PASS |
Complete Store × Consumer combination matrix — all 5 combinations verified.
| # | Store Mode | Consumer | Test Image | Transfer | Result |
|---|---|---|---|---|---|
| 1 | docker | Docker (docker pull) |
redis:7-alpine | manifest 200, library/ prefix normalized | PASS |
| 2 | docker | containerd (ctr pull) |
redis:7-alpine | 40.6 MiB, all layers pulled & unpacked (3.5 s) | PASS |
| 3 | containerd | Docker (curl verification) |
debian:bookworm-slim | OCI manifest returned; all blobs accessible | PASS |
| 4 | auto (MultiStore) | Docker (curl verification) |
redis:7-alpine | Docker-store image resolved via MultiStore | PASS |
| 5 | auto (MultiStore) | containerd (ctr pull) |
debian:bookworm-slim | 77.9 MiB pulled from containerd store, unpacked | PASS |
docker store initialized images=2 tags=4 blobs=2 layers=9
containerd store initialized images=22 tags=64 blobs=152
multiple stores detected, using MultiStore count=2
Compared against Spegel, the closest open-source alternative.
| Metric | OutpostImage | Spegel (reference) | Reduction |
|---|---|---|---|
| Direct dependencies | 7 | ~22 | -68% |
| Total dependencies (incl. indirect) | 56 | ~130 | -57% |
| Binary size | 11 MB | ~35–40 MB | -72% |
| Dynamic linking | None (fully static) | — | — |
$ ldd outpostimage
not a dynamic executable
Both VMs fully isolated from the internet via iptables; image sharing via LAN only.
# Applied on both VMs to cut external internet access
iptables -I OUTPUT -d 192.168.1.0/24 -j ACCEPT
iptables -I OUTPUT -d 127.0.0.0/8 -j ACCEPT
iptables -A OUTPUT -j DROP
# Verified external registry unreachable
$ curl --connect-timeout 3 https://registry-1.docker.io/v2/
curl: (28) Connection timed out
# Images pulled successfully over LAN
$ docker pull 192.168.1.102:15000/library/redis:7-alpine
Status: Downloaded newer image for 192.168.1.102:15000/library/redis:7-alpine
$ docker run --rm 192.168.1.102:15000/library/redis:7-alpine redis-server --version
Redis server v=7.4.7
Simulated edge AI model distribution between two QEMU VMs (1 vCPU, 2 GB RAM each). Raw TCP baseline: 5.4 Gbps / ~675 MB/s.
| Scenario | Image Size | Transfer Rate | Time | Result |
|---|---|---|---|---|
| 500 MB model (first pull) | 603 MB | 18.84 MB/s | 32 s | PASS |
| 2 GB model (first pull) | 2.23 GB | 15.86 MB/s | 144 s | PASS |
| Repeat pull (Docker layer cache hit) | 603 MB | ∞ (local) | 123 ms (260x faster) | PASS |
| Parallel pull (500 MB + 2 GB simultaneous) | 2.83 GB total | — | 129 s (27% faster than serial 176 s) | PASS |
Transfer throughput is CPU-bound (real-time tar streaming from overlay2 diff dirs), not network-bound. Future optimization: pre-computed tar cache.
Fixes validated in v0.1.0 (commit bb232a0), tested on T480 (100-layer Docker store).
| Optimization | Before | After | Result |
|---|---|---|---|
| Startup time (100-layer Docker store) | ~80 s (eager tar computation) | 14 ms (lazy loading) | PASS |
| First manifest request (lazy build included) | N/A | ~760 ms | PASS |
| Subsequent manifest (cache hit) | N/A | <1 ms | PASS |
| HTTPS MITM — CONNECT interception | Not connected | Dynamic cert generated; TLS decrypted | PASS |
| Proxy mode: manifest/blob routing | All blobs routed to upstream | Local/P2P first, upstream only on miss | PASS |
Up and running in minutes on any Linux node.
# Download the latest binary
curl -L https://github.com/outpostos/outpostimage/releases/latest/download/outpostimage-linux-amd64 \
-o /usr/local/bin/outpostimage && chmod +x /usr/local/bin/outpostimage
# Run with defaults (auto-detects Docker or containerd store)
outpostimage registry
# Run with explicit peers
outpostimage registry --peers node2:5000 --peers node3:5000
docker run -d \
--name outpostimage \
--network host \
-v /var/lib/docker:/var/lib/docker:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
outpostimage:latest registry
git clone https://github.com/outpostos/outpostimage
cd outpostimage
# Optional: create peers.txt with other node addresses
echo "192.168.1.101:5000" > peers.txt
echo "192.168.1.102:5000" >> peers.txt
docker compose up -d
# /etc/docker/daemon.json — point Docker at the proxy
{
"proxies": {
"http-proxy": "http://localhost:3128",
"https-proxy": "http://localhost:3128"
}
}
# Trust the OutpostImage CA certificate
cp /etc/outpostimage/ca.crt /usr/local/share/ca-certificates/outpostimage-ca.crt
update-ca-certificates
Deploy on your edge nodes in minutes. No central registry, no extra storage, no vendor lock-in.