How to back up Docker images (and when you don't need to)
Most images do not need a backup: they come from a registry or a Dockerfile, and your data lives in volumes, not images. The ones that do are images you could not pull or rebuild exactly, such as builds that exist only on the server or vendor images that may be withdrawn. Save those with docker save -o images.tar myapp:1.4, bring them back with docker load -i images.tar, and pin the rest by digest.
Which images need a copy
Anything worth keeping should sit in a volume, and no image contains your volumes. Back those up first: see how to back up Docker volumes and databases in Docker Compose. Then sort your images:
| Image | Keep a copy? | Why |
|---|---|---|
Public image pulled by tag, such as postgres:17 | No, pin its digest | You can pull it again, but the tag moves to newer builds |
| Vendor image that could be withdrawn or moved | Yes, in your registry or an archive | From August 28, 2025, Bitnami moved its public images to an unmaintained bitnamilegacy repository and scheduled docker.io/bitnami for deletion |
| Image you build from a Dockerfile in git | The exact build you deploy, yes | A rebuild months later pulls newer base images and packages, so it is a different image |
| Image built or changed only on the server | Yes | There is no other copy |
| Image in your own private registry | Back up the registry | The registry is the only copy |
Switching Docker to the containerd image store hides the images made with the old storage driver: they stay on disk but are no longer listed. Docker's documentation says to push them to a registry or docker save them first. Docker Engine 29 uses the containerd store on fresh installs.
List the images a host runs
Start with what is running. .Image is the image reference each container was created from, or an image ID if that tag has since moved to another image:
docker ps --format '{{.Names}}\t{{.Image}}'docker inspect prints the exact image ID behind each running container, which a tag cannot tell you:
docker inspect --format '{{.Name}} {{.Config.Image}} {{.Image}}' $(docker ps -q)For a Compose project, docker compose config --images prints the image of every service, one per line. A service with only a build: section gets the default name, the project and service names joined by a dash, such as shop-web.
docker compose config --imagesTo see an image's registry digest, the content hash that names one exact version:
docker image inspect --format '{{json .RepoDigests}}' postgres:17It prints references such as postgres@sha256: followed by 64 hex characters. Only images pulled from or pushed to a registry have one, so a local build prints [].
Save images to a file
docker save writes one or more images, with every layer, tag and setting, to a tar archive that docker load reads back:
docker save -o shop-images.tar shop-web:1.4 postgres:17-owrites to a file. Without it,docker savewrites to standard output and refuses when that is your terminal.- Name images as
name:tag. Saving by image ID works, but the image comes back untagged. - Images saved into one archive store the layers they share only once.
--platform linux/amd64keeps only that platform when the containerd image store holds several.
Compress the archive as you write it. docker load reads archives compressed with gzip, bzip2, xz or zstd directly, so there is no need to decompress first:
docker save shop-web:1.4 postgres:17 | gzip > shop-images.tar.gzdocker save shop-web:1.4 postgres:17 | zstd -q -T0 -o shop-images.tar.zst-T0 gives zstd one thread per CPU core and -q hides its progress line. zstd refuses to overwrite an existing file when reading standard input, with already exists; stdin is an input - not proceeding., so add -f or use a new name. Check the archive: zstd --test (or gzip -t) reads the whole file, and every image archive contains a manifest.json:
zstd --test shop-images.tar.zstzstd -dc shop-images.tar.zst | tar -tf - manifest.jsonLoad images on a new host
Copy the archive across, for example with scp or rsync (copying files between servers), then load it. Docker prints a Loaded image: line for each tag:
docker load -i shop-images.tar.zstOr skip the file and stream the images from the old host straight into the new one:
docker save shop-web:1.4 postgres:17 | zstd -q -T0 | ssh root@new-host docker loadStart the project with exactly the images you loaded:
docker compose up -d --pull never --no-build--pull never stops Compose from swapping a loaded image for whatever the registry has now, and makes a missing image an error instead of a download. --no-build does the same for build: services. Then restore the volumes as in the volume guide.
If your Compose file pins images by digest, pull them from your registry instead of loading an archive. On Docker's classic image store, docker load restores tags but not registry digests, so the pinned reference finds no local match.
Load onto the same CPU architecture. An amd64 image on an arm64 host starts with a platform warning and then fails with exec format error. Save the matching platform on a host that has it, or rebuild for the new one.
Pin images by digest
A tag such as postgres:17 moves with each new build; a digest never changes. Pin the one you tested, and every host pulls the same image while the registry keeps it:
services:
db:
# postgres:17 as of 2026-10-04
image: postgres@sha256:<digest>Copy the digest from RepoDigests above; docker pull postgres@sha256:<digest> pulls it by hand. Docker's pull reference points out the cost: a pinned image gets no updates, security fixes included, until you change the digest. Bump it on purpose, as you would a package version.
docker compose config --resolve-image-digests rewrites every image as a digest, but it asks the registry what each tag points to now, which may not be what you run. To pin what is running, read the local RepoDigests.
Keep copies in a registry
A registry stores each layer once however many versions use it, and restoring is an ordinary docker pull. Docker Hub private repositories, GitHub Container Registry, your cloud's registry or your own all work. For GitHub, log in with a personal access token (classic) with the write:packages scope:
echo "$CR_PAT" | docker login ghcr.io -u <github-user> --password-stdindocker tag vendor/tool:2.1 ghcr.io/<org>/tool:2.1docker push ghcr.io/<org>/tool:2.1GitHub makes a newly published package private and limits each layer to 10 GB. Pin the digest docker push prints: it can differ from the vendor's, because Docker may push only the platform it has locally rather than the vendor's multi-platform index.
To keep copies on your own disk, run the open-source registry from the Distribution project on the server, listening on localhost only. Docker accepts plain HTTP from registries in 127.0.0.0/8, so it needs no certificate there:
docker run -d --restart=always --name registry -p 127.0.0.1:5000:5000 -v /srv/registry:/var/lib/registry -e OTEL_TRACES_EXPORTER=none registry:3-p 127.0.0.1:5000:5000publishes the port on the loopback address only.-v /srv/registry:/var/lib/registrykeeps the registry's data in a host directory that you back up like any other.OTEL_TRACES_EXPORTER=noneturns off the trace export the default configuration attempts.registry:3is Distribution's current major version (v3.1.2 as of October 2026).
docker tag shop-web:1.4 localhost:5000/shop-web:1.4docker push localhost:5000/shop-web:1.4To use the registry from other machines, Distribution's documentation requires TLS first, then access control such as basic authentication with a bcrypt htpasswd file.
docker export and docker commit are not image backups
| Command | What you get | What is missing |
|---|---|---|
docker save | Images with their layers, tags and settings | Volume contents |
docker export | One container's files, flattened into a single tar | Layers, history, settings such as CMD, ENTRYPOINT, ENV and exposed ports, and volume contents |
docker commit | A new image: the container's image plus its changes as one more layer | Volume contents, and any way to rebuild it from source |
docker export writes a container's filesystem. Where a volume is mounted, it exports the directory underneath, not the volume. To turn an export back into an image, docker import needs a --change for each setting the export lost:
docker import --change 'CMD ["node", "server.js"]' web-export.tar shop-web:restoreddocker commit pauses the container and saves its writable layer as a new image, but skips volumes. If a container holds data you would lose without a commit, it writes outside its volumes: move that path into a volume and back the volume up.
Save running images nightly with cron
This script saves every image the running containers use into one dated archive, tests it, and deletes archives older than 14 days:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/docker-images"
KEEP_DAYS=14
OUT="$BACKUP_DIR/images-$(date +%Y-%m-%d).tar.zst"
mkdir -p "$BACKUP_DIR"
IMAGES="$(docker ps --format '{{.Image}}' | sort -u)"
docker save $IMAGES | zstd -q -T0 -f -o "$OUT.partial"
zstd -q --test "$OUT.partial"
mv "$OUT.partial" "$OUT"
find "$BACKUP_DIR" -name 'images-*.tar.zst' -type f -mtime +"$KEEP_DAYS" -deletesudo chmod 755 /usr/local/bin/docker-image-backup.sh15 3 * * * root /usr/local/bin/docker-image-backup.sh >> /var/log/docker-image-backup.log 2>&1$IMAGES is unquoted on purpose, so each name becomes its own argument; with no containers running, docker save gets no names and the script fails. pipefail makes a failed docker save fail the pipe, and the archive gets its final name only after zstd --test passes. Whole stacks can run to gigabytes: check the first archive and set KEEP_DAYS to match, or list only the images you could not pull again. Copy the archives off the server, for example with rclone; see also the cron guide.
Common errors
| Error | Cause and fix |
|---|---|
cowardly refusing to save to a terminal. Use the -o flag or redirect | docker save had nowhere to write. Add -o file.tar or pipe it into gzip or zstd. |
requested load from stdin, but stdin is empty | docker load got no input. Add -i file.tar or redirect the file into it. |
invalid archive: does not contain a manifest.json, or unrecognized image format on the containerd store | The file is not a docker save archive, often a docker export tarball, which needs docker import instead. |
No such image: shop-web:1.4 from docker save, or reference does not exist on the classic image store | No local image has that name and tag. Check docker image ls. |
pull access denied for shop-web, repository does not exist or may require 'docker login' | Compose tried to pull an image that only existed locally. Load the archive first. |
denied: requested access to the resource is denied on push | Log in with docker login and tag the image under your own user or organization. |
http: server gave HTTP response to HTTPS client | The registry uses plain HTTP and is not on 127.0.0.0/8. Give it TLS; insecure-registries in daemon.json is for testing only. |
The requested image's platform (linux/amd64) does not match the detected host platform, then exec format error | The image was built for another CPU architecture. Use one built for this host. |
Frequently asked questions
- Do I need to back up Docker images?
- Usually not public ones: pin them by digest and pull them again. Keep copies of images you could not pull or rebuild exactly, such as builds that exist only on the server or vendor images that may be withdrawn. Your data belongs in volumes, which images do not contain.
- What is the difference between docker save and docker export?
- docker save writes images with their layers, tags and settings, and docker load restores them. docker export writes one container's files as a flat tar with no image settings, and docker import turns that into a new single-layer image.
- Can docker load read a compressed archive?
- Yes. docker load reads tar archives compressed with gzip, bzip2, xz or zstd, from a file with -i or from standard input.
- How do I copy a Docker image to another server without a registry?
- Pipe docker save into ssh and run docker load on the other side, for example docker save myimage:1.4 | ssh root@new-host docker load.
How this was checked
Commands, limits and prices were checked against these official pages, on October 4, 2026:
- Docker CLI reference: docker image save
- Docker CLI reference: docker image load
- Docker CLI reference: docker container export
- Docker CLI reference: docker image import
- Docker CLI reference: docker container commit
- Docker CLI reference: docker image pull (pull by digest)
- Docker CLI reference: docker image push
- Docker CLI reference: docker image tag
- Docker CLI reference: docker image inspect
- Docker CLI reference: docker container ls (format placeholders)
- Docker CLI reference: docker compose config
- Docker CLI reference: docker compose up
- Compose file reference: services (image, pull_policy)
- Docker Engine 29 release notes (containerd store default, save/load --platform)
- Docker documentation: containerd image store
- Docker CLI reference: dockerd (insecure registries, 127.0.0.0/8)
- Docker Engine API v29.8.2 spec (RepoDigests, container Image fields)
- Docker CLI v29.8.2 source: image save (errors, required arguments)
- Docker CLI v29.8.2 source: image load (errors)
- Moby v29.8.2 source: classic image store load (tags only, manifest.json error)
- containerd archive importer, as vendored in Moby v29.8.2
- Moby v29.8.2 source: distribution errors (pull access denied)
- Moby v29.8.2 source: container create (platform mismatch warning)
- Docker Compose v5.6.0 source: pull (pull policies, registry digest resolver)
- Distribution: Deploy a registry server
- Distribution source: registry error codes (denied)
- Go source: net/http (HTTP response to HTTPS client error)
- GitHub Docs: Working with the Container registry
- Bitnami: Upcoming changes to the Bitnami catalog (GitHub issue)
- zstd 1.5.5 manual