Docker Save and Load Images: Move Images Offline (vs Export/Import and Registries)
Most of the time an image travels through a registry: docker push on one machine, docker pull on another. But sometimes there is no registry in the middle: an air-gapped server, a customer site without internet, a CI job that must hand an image to another job, or a laptop you want to restore after a rebuild. docker save writes one or more images to a tar archive and docker load reads them back, preserving tags, layers and history. This tutorial covers the full workflow, compression, multi-image bundles, the difference with docker export/import, and the equivalent commands in containerd (ctr, nerdctl) and Podman.
Prerequisites: Docker Engine 27+ (docker version), a few hundred MB of disk, and basic image knowledge from Docker Images.
save/load vs export/import at a glance
docker save / docker load | docker export / docker import | |
|---|---|---|
| Works on | Images | Containers (a running or stopped container’s filesystem) |
| Preserves layers | Yes, all layers and their metadata | No, flattens to a single layer |
| Preserves tags, ENV, CMD, ENTRYPOINT, EXPOSE, history | Yes | No (you can re-add some with --change) |
| Multiple images in one archive | Yes | No |
| Typical use | Move or back up images offline | Snapshot a container as a fresh, flat image |
Step 1 – Save an image to a tar file
docker pull nginx:1.27-alpine
docker image ls nginx
# REPOSITORY TAG IMAGE ID SIZE
# nginx 1.27-alpine 3f9a5f6b1c2d 48.3MB
# Save by name:tag (recommended – keeps the tag in the archive)
docker save -o nginx-1.27-alpine.tar nginx:1.27-alpine
# Equivalent with a redirect
docker save nginx:1.27-alpine > nginx-1.27-alpine.tar
ls -lh nginx-1.27-alpine.tar
# -rw------- 1 user user 49M ... nginx-1.27-alpine.tarIf you save by image ID (docker save 3f9a5f6b1c2d) the archive contains no repository or tag; after docker load the image shows up as <none>:<none> and you must docker tag it manually. Always save by name:tag.
Step 2 – Compress it
Image layers are already gzip-compressed inside a registry, but docker save writes them uncompressed. Compressing the tar typically shrinks it 2–3×. docker load detects gzip, bzip2, xz and zstd automatically.
# gzip (universal)
docker save nginx:1.27-alpine | gzip > nginx-1.27-alpine.tar.gz
# zstd (much faster, similar ratio; needs zstd on both sides)
docker save nginx:1.27-alpine | zstd -T0 -o nginx-1.27-alpine.tar.zst
ls -lh nginx-1.27-alpine.tar*
# 49M nginx-1.27-alpine.tar
# 20M nginx-1.27-alpine.tar.gz
# 19M nginx-1.27-alpine.tar.zstStep 3 – Transfer and load on the target host
# Copy over SSH (or USB key, S3, artifact store…)
scp nginx-1.27-alpine.tar.gz admin@airgapped-host:/tmp/
# On the target
ssh admin@airgapped-host
docker load -i /tmp/nginx-1.27-alpine.tar.gz
# Loaded image: nginx:1.27-alpine
docker image ls nginx
docker run -d --name web -p 8080:80 nginx:1.27-alpine
curl -s localhost:8080 | head -3No temporary file needed? Stream directly over SSH:
docker save nginx:1.27-alpine | gzip | ssh admin@airgapped-host 'docker load'Step 4 – Bundle several images in one archive
Shipping a whole Compose stack offline is a common need. docker save accepts many images and de-duplicates shared layers inside the archive.
# All images referenced by a compose file
cd my-stack
docker compose pull
IMAGES=$(docker compose config --images)
echo "$IMAGES"
docker save $IMAGES | zstd -T0 -o my-stack-images.tar.zst
# On the target
zstd -dc my-stack-images.tar.zst | docker load
docker compose up -d --pull never # never try to reach a registryStep 5 – Inspect an archive without loading it
tar -tf nginx-1.27-alpine.tar | head
# blobs/sha256/... (layers and configs, OCI layout)
# index.json
# manifest.json
# oci-layout
# repositories
tar -xOf nginx-1.27-alpine.tar manifest.json | jq .
# [{ "Config": "blobs/sha256/...", "RepoTags": ["nginx:1.27-alpine"], "Layers": [ ... ] }]Since Docker 25 the archive follows the OCI image layout while keeping the legacy manifest.json, so tools such as skopeo, crane, containerd and Podman can read it too.
Multi-platform images
By default docker save exports only the platform variant present in your local store (e.g. linux/amd64). To ship an image for an ARM server from an Intel laptop, pull the right variant first, or use the containerd image store which keeps all variants:
# Pull a specific platform and save it
docker pull --platform linux/arm64 nginx:1.27-alpine
docker save --platform linux/arm64 nginx:1.27-alpine -o nginx-arm64.tar # Docker 27.5+
# Alternative without Docker: copy directly from the registry to a tar with skopeo
skopeo copy --all docker://nginx:1.27-alpine oci-archive:nginx-all-platforms.tardocker export / import: snapshot a container
Use these when you want the current filesystem of a container as a flat image, for example to capture a manually configured legacy system before writing a proper Dockerfile.
docker export web | gzip > web-rootfs.tar.gz
# Import as a new image, restoring the metadata that export dropped
zcat web-rootfs.tar.gz | docker import
--change 'CMD ["nginx", "-g", "daemon off;"]'
--change 'EXPOSE 80'
- myorg/web-snapshot:2026-09-10
docker history myorg/web-snapshot:2026-09-10 # a single layerEquivalents in other tools
| Tool | Save | Load |
|---|---|---|
| Podman | podman save -o img.tar --format oci-archive nginx:1.27-alpine | podman load -i img.tar |
| nerdctl (containerd) | nerdctl save -o img.tar nginx:1.27-alpine | nerdctl load -i img.tar |
| ctr (containerd, Kubernetes nodes) | ctr -n k8s.io images export img.tar docker.io/library/nginx:1.27-alpine | ctr -n k8s.io images import img.tar |
| skopeo (no daemon) | skopeo copy docker://nginx:1.27-alpine docker-archive:img.tar | skopeo copy docker-archive:img.tar docker-daemon:nginx:1.27-alpine |
The ctr line matters for Kubernetes: to preload an image on a kubeadm node without a registry, import it into the k8s.io namespace and set imagePullPolicy: IfNotPresent.
When a registry is still the better answer
- More than a handful of hosts: pulling from a private registry (Harbor, ECR, GitLab, or a plain
registry:2container) is faster and de-duplicates layers per host. - Repeated updates: a registry transfers only changed layers; a tar always contains everything.
- Signing and scanning: Cosign, Notation and vulnerability scanners work on registries, not on tar files.
- Air-gapped but many hosts: run a registry inside the isolated network and use
docker load+docker pushonce to seed it.
Troubleshooting
- “open /var/lib/docker/tmp/…: no space left on device” –
docker loadextracts to Docker’s data root first; free space or movedata-root. - Image loaded as
<none>:<none>– saved by ID; retag withdocker tag <id> name:tag. - “no matching manifest for linux/arm64” – archive contains only amd64 layers; save with
--platformor use skopeo--all. - Slow save on large images – pipe through
zstd -T0instead of gzip, or usepigzfor parallel gzip.
Use case: hand an image between CI jobs
Pipelines often build in one job and test in another on a different runner. Saving the image as an artifact avoids pushing untested images to the registry:
# GitHub Actions excerpt
- run: docker build -t app:ci . && docker save app:ci | zstd -T0 -o app.tar.zst
- uses: actions/upload-artifact@v4
with: { name: image, path: app.tar.zst, retention-days: 1 }
# … in the test job:
- uses: actions/download-artifact@v4
with: { name: image }
- run: zstd -dc app.tar.zst | docker load && docker run --rm app:ci pytestKey takeaways
docker save name:tag→ tar;docker load -i→ image with tags and layers intact.- Always compress (gzip or zstd) and always save by name:tag, not by ID.
- One archive can carry a whole Compose stack;
docker compose config --imageslists what to include. export/importflatten a container;save/loadpreserve an image.- Beyond a few hosts, seed a private registry instead of copying tar files around.
Next tutorial
Next: Run your own Docker registry and Docker Compose. Official docs: docker image save, docker image load.
Retour parcours Docker — hub de la série et leçons sœurs.



