VPS Snaps

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.

10 min readUpdated Checked against official documentation

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:

ImageKeep a copy?Why
Public image pulled by tag, such as postgres:17No, pin its digestYou can pull it again, but the tag moves to newer builds
Vendor image that could be withdrawn or movedYes, in your registry or an archiveFrom 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 gitThe exact build you deploy, yesA rebuild months later pulls newer base images and packages, so it is a different image
Image built or changed only on the serverYesThere is no other copy
Image in your own private registryBack up the registryThe 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:

Terminal
docker ps --format '{{.Names}}\t{{.Image}}'

docker inspect prints the exact image ID behind each running container, which a tag cannot tell you:

Terminal
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.

Terminal
docker compose config --images

To see an image's registry digest, the content hash that names one exact version:

Terminal
docker image inspect --format '{{json .RepoDigests}}' postgres:17

It 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:

Terminal
docker save -o shop-images.tar shop-web:1.4 postgres:17
  • -o writes to a file. Without it, docker save writes 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/amd64 keeps 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:

Terminal
docker save shop-web:1.4 postgres:17 | gzip > shop-images.tar.gz
Terminal
docker 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:

Terminal
zstd --test shop-images.tar.zst
Terminal
zstd -dc shop-images.tar.zst | tar -tf - manifest.json

Load 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:

Terminal
docker load -i shop-images.tar.zst

Or skip the file and stream the images from the old host straight into the new one:

Terminal
docker save shop-web:1.4 postgres:17 | zstd -q -T0 | ssh root@new-host docker load

Start the project with exactly the images you loaded:

Terminal
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:

compose.yaml
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:

Terminal
echo "$CR_PAT" | docker login ghcr.io -u <github-user> --password-stdin
Terminal
docker tag vendor/tool:2.1 ghcr.io/<org>/tool:2.1
Terminal
docker push ghcr.io/<org>/tool:2.1

GitHub 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:

Terminal
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:5000 publishes the port on the loopback address only.
  • -v /srv/registry:/var/lib/registry keeps the registry's data in a host directory that you back up like any other.
  • OTEL_TRACES_EXPORTER=none turns off the trace export the default configuration attempts. registry:3 is Distribution's current major version (v3.1.2 as of October 2026).
Terminal
docker tag shop-web:1.4 localhost:5000/shop-web:1.4
Terminal
docker push localhost:5000/shop-web:1.4

To 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

CommandWhat you getWhat is missing
docker saveImages with their layers, tags and settingsVolume contents
docker exportOne container's files, flattened into a single tarLayers, history, settings such as CMD, ENTRYPOINT, ENV and exposed ports, and volume contents
docker commitA new image: the container's image plus its changes as one more layerVolume 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:

Terminal
docker import --change 'CMD ["node", "server.js"]' web-export.tar shop-web:restored

docker 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/local/bin/docker-image-backup.sh
#!/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" -delete
Terminal
sudo chmod 755 /usr/local/bin/docker-image-backup.sh
/etc/cron.d/docker-image-backup
15 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

ErrorCause and fix
cowardly refusing to save to a terminal. Use the -o flag or redirectdocker save had nowhere to write. Add -o file.tar or pipe it into gzip or zstd.
requested load from stdin, but stdin is emptydocker 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 storeThe 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 storeNo 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 pushLog in with docker login and tag the image under your own user or organization.
http: server gave HTTP response to HTTPS clientThe 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 errorThe 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: