You have spent three lessons building. Every docker build from the previous ones has left layers on your disk, and by now your machine has accumulated auroralibros/aurora-api in several versions, the node, postgres, redis, nginx and alpine base images, the lab images from the exercises and a BuildKit cache you have never looked at. This lesson is about administering that store: knowing what you have, why it takes up what it takes up, how to audit what an image is made of, how to delete without wiping out what you need, where those mysterious <none>:<none> images that show up build after build come from, and how to take an image to another machine when there is no registry involved. It is the least flashy lesson of the module and the one that will save you the most grief: a misunderstood docker system prune -a --volumes destroys in ten seconds data that took weeks to produce.

Contents

  1. Listing images: docker image ls and its options
  2. Inspecting: docker image inspect and --format
  3. Auditing how it was made: docker image history
  4. Deleting images: docker image rm
  5. Dangling images: the <none>:<none> ones
  6. Cleaning up with the prune family
  7. Diagnosing disk space: docker system df
  8. Moving images without a registry: save/load and export/import
  9. A recommended maintenance routine

  1. Listing images: docker image ls and its options

The starting point, using the docker <object> <action> grammar from lesson 01-04:

docker image ls
REPOSITORY                TAG          IMAGE ID       CREATED          SIZE
auroralibros/aurora-api   1.1.0        4e9c7d2a8f31   10 minutes ago   167MB
auroralibros/aurora-api   1.0.0        8c1e4a7f2b9d   35 minutes ago   167MB
auroralibros/aurora-api   0.1.0        6b4d2f8e1a3c   58 minutes ago   167MB
node                      22-alpine    9f2c1a5e7b04   6 days ago       142MB
postgres                  16-alpine    b71c3d8f4a29   9 days ago       278MB
redis                     7-alpine     3e5a9c1d7b82   9 days ago       41.4MB
nginx                     alpine       c8d4f2a91e37   11 days ago      52.5MB
alpine                    3.21         a1e7f9c34d02   3 weeks ago      8.17MB
registry                  2            7f1e2b8c5a43   2 months ago     25.4MB

docker images is the short alias and does exactly the same thing.

A fundamental warning about the SIZE column, which you already suspected in lesson 01-05: the sizes do not add up. The three versions of aurora-api each say 167 MB, but they do not take up 501 MB on disk. They share the node:22-alpine base and a good part of their layers; the real space is what docker system df will tell you in section 7.

Listing options

# -a: includes intermediate layers (with the classic builder)
docker image ls -a

# --digests: shows each image's sha256 digest
docker image ls --digests node

# -q: only the IDs, ideal for chaining commands
docker image ls -q

# --no-trunc: full IDs, not abbreviated
docker image ls --no-trunc
docker image ls --digests auroralibros/aurora-api
REPOSITORY                TAG     DIGEST                              IMAGE ID       SIZE
auroralibros/aurora-api   1.1.0   <none>                              4e9c7d2a8f31   167MB
auroralibros/aurora-api   1.0.0   <none>                              8c1e4a7f2b9d   167MB

The digest comes out as <none> because these images were built locally and have never been published: the manifest digest is assigned by the registry when it receives the push. As soon as you publish them in lesson 02-06, that column will fill in.

Filtering with --filter

# Images from a specific repository
docker image ls auroralibros/aurora-api

# With a wildcard in the name
docker image ls "auroralibros/*"

# Only the dangling ones (section 5)
docker image ls --filter "dangling=true"

# Older than a given image
docker image ls --filter "before=auroralibros/aurora-api:1.0.0"

# Newer than a given image
docker image ls --filter "since=node:22-alpine"

# By OCI label, the ones you added in 02-04
docker image ls --filter "label=org.opencontainers.image.vendor=Aurora Libros S.L."
REPOSITORY                TAG     IMAGE ID       CREATED          SIZE
auroralibros/aurora-api   1.1.0   4e9c7d2a8f31   12 minutes ago   167MB

Only 1.1.0 shows up, because it is the only one carrying the OCI labels. Here you can see the practical payoff of the previous lesson's work: the metadata was not decoration, it is a queryable index.

Custom formatting with Go templates

Bringing back --format from lesson 01-04:

docker image ls --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}"
REPOSITORY:TAG                        SIZE      CREATED
auroralibros/aurora-api:1.1.0         167MB     13 minutes ago
auroralibros/aurora-api:1.0.0         167MB     38 minutes ago
node:22-alpine                        142MB     6 days ago

Available fields: .ID, .Repository, .Tag, .Digest, .CreatedSince, .CreatedAt, .Size.

Without the word table, the output is plain text, perfect for scripts:

docker image ls --format "{{.Repository}}:{{.Tag}}" --filter "reference=auroralibros/*"
auroralibros/aurora-api:1.1.0
auroralibros/aurora-api:1.0.0
auroralibros/aurora-api:0.1.0

And in JSON, to process with jq:

docker image ls --format json | head -1
{"Containers":"N/A","CreatedAt":"2026-08-04 11:42:03 +0200 CEST","CreatedSince":"14 minutes ago","Digest":"<none>","ID":"4e9c7d2a8f31","Repository":"auroralibros/aurora-api","Size":"167MB","Tag":"1.1.0"}

  1. Inspecting: docker image inspect and --format

docker image inspect dumps all of an image's metadata as JSON. With no filter it is hundreds of lines:

docker image inspect auroralibros/aurora-api:1.1.0 | wc -l
187

The trick is extracting only what you need. The --format syntax is the same as in lesson 01-04, and these are the fields you will use most:

# The process that starts the container
docker image inspect auroralibros/aurora-api:1.1.0 --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}'
["node"] ["server.js"]
# Environment variables, one per line
docker image inspect auroralibros/aurora-api:1.1.0 --format '{{range .Config.Env}}{{println .}}{{end}}'
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NODE_VERSION=22.14.0
YARN_VERSION=1.22.22
NODE_ENV=production
PORT=3000
APP_VERSION=1.1.0

This is the command you use to audit that there is no secret inside an image before publishing it. Make it a reflex.

# User, working directory and declared ports
docker image inspect auroralibros/aurora-api:1.1.0 \
  --format 'User:     {{.Config.User}}
WorkDir:  {{.Config.WorkingDir}}
Ports:    {{range $p, $_ := .Config.ExposedPorts}}{{$p}} {{end}}'
User:     node
WorkDir:  /app
Ports:    3000/tcp
# Architecture and operating system: critical on ARM machines
docker image inspect node:22-alpine --format '{{.Os}}/{{.Architecture}} · Docker {{.DockerVersion}}'
linux/amd64 · Docker 27.4.1

If this said linux/amd64 and you were on a Mac with Apple Silicon, the image would run under emulation, which is much slower. It is the first thing to check when a container is inexplicably slow.

# The layers: the filesystem digests
docker image inspect auroralibros/aurora-api:1.1.0 --format '{{range .RootFS.Layers}}{{println .}}{{end}}'
sha256:4a1b8c2f9e3d7a5b1c8f2e6d4a9b3c7e1f5d8a2b6c4e9f3a7d1b5c8e2f6a4d9b
sha256:8f3e1d7c5b9a2f4e8d6c1b3a7f9e5d2c8b4a6f1e3d7c9b5a2f8e4d6c1b3a7f9e
...
# A quick layer count: a good complexity indicator
docker image inspect auroralibros/aurora-api:1.1.0 --format '{{len .RootFS.Layers}} layers'
7 layers
# The OCI labels from lesson 02-04
docker image inspect auroralibros/aurora-api:1.1.0 \
  --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'
7a3f912

One command, and you know exactly which commit is inside that image.

You can inspect several at once and compare:

docker image inspect --format '{{.RepoTags}} → {{.Size}} bytes, {{len .RootFS.Layers}} layers' \
  auroralibros/aurora-api:1.1.0 node:22-alpine alpine:3.21
[auroralibros/aurora-api:1.1.0] → 167142891 bytes, 7 layers
[node:22-alpine] → 142331904 bytes, 4 layers
[alpine:3.21] → 8172544 bytes, 1 layers

There is the complete genealogy: Alpine contributes 1 layer, Node adds 3 more, and your Dockerfile the remaining 3 (the two COPYs and the RUN).

  1. Auditing how it was made: docker image history

docker image history reconstructs an image's build history layer by layer. It is the auditing tool par excellence and you already used it in lesson 02-01 to evaluate other people's images.

docker image history auroralibros/aurora-api:1.1.0
IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
4e9c7d2a8f31   18 minutes ago   CMD ["server.js"]                               0B        buildkit.dockerfile.v0
<missing>      18 minutes ago   ENTRYPOINT ["node"]                             0B        buildkit.dockerfile.v0
<missing>      18 minutes ago   HEALTHCHECK &{["CMD-SHELL" "wget --quiet --…    0B        buildkit.dockerfile.v0
<missing>      18 minutes ago   EXPOSE map[3000/tcp:{}]                         0B        buildkit.dockerfile.v0
<missing>      18 minutes ago   USER node                                       0B        buildkit.dockerfile.v0
<missing>      18 minutes ago   ENV NODE_ENV=production PORT=3000 APP_VERSI…    0B        buildkit.dockerfile.v0
<missing>      18 minutes ago   COPY . . # buildkit                             52.1kB    buildkit.dockerfile.v0
<missing>      18 minutes ago   RUN /bin/sh -c npm ci --omit=dev && npm cac…    24.7MB    buildkit.dockerfile.v0
<missing>      18 minutes ago   COPY package*.json ./ # buildkit                44.6kB    buildkit.dockerfile.v0
<missing>      18 minutes ago   WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      18 minutes ago   LABEL org.opencontainers.image.title=auror…     0B        buildkit.dockerfile.v0
<missing>      6 days ago       CMD ["node"]                                    0B        buildkit.dockerfile.v0
<missing>      6 days ago       ENTRYPOINT ["docker-entrypoint.sh"]             0B        buildkit.dockerfile.v0
<missing>      6 days ago       RUN /bin/sh -c apk add --no-cache --virtual…    7.82MB    buildkit.dockerfile.v0
<missing>      6 days ago       ENV NODE_VERSION=22.14.0                        0B        buildkit.dockerfile.v0
<missing>      6 days ago       /bin/sh -c #(nop) ADD file:1b8a2c9e4d7f… in /   8.17MB

How to read it:

  • You read it from the bottom up. The last line is the first layer (Alpine's base filesystem); the first line is the most recent instruction.
  • <missing> is not an error. Only the top layer has an ID of its own; the intermediate ones of an image built with BuildKit do not expose theirs. That is normal.
  • The SIZE column is the one that matters. There you can see that the RUN npm ci contributes 24.7 MB and the two COPYs barely 52 kB and 44 kB between them. All the metadata — ENV, EXPOSE, USER, LABEL, HEALTHCHECK, ENTRYPOINT, CMD — weighs 0 B, exactly as announced in the previous lesson.
  • The history includes the base image's. The "6 days" lines belong to node:22-alpine: you can see how its maintainers built it.

Finding the heaviest layer

It is the command's most profitable use:

docker image history auroralibros/aurora-api:1.1.0 --format "{{.Size}}\t{{.CreatedBy}}" --no-trunc | sort -rh | head -5
24.7MB   RUN /bin/sh -c npm ci --omit=dev && npm cache clean --force # buildkit
8.17MB   /bin/sh -c #(nop) ADD file:1b8a2c9e4d7f... in /
7.82MB   RUN /bin/sh -c apk add --no-cache --virtual .build-deps ...
52.1kB   COPY . . # buildkit
44.6kB   COPY package*.json ./ # buildkit

When an image weighs 900 MB and you do not know why, this command gives you the answer in a second. The usual suspects: uncleaned package manager caches, build tools that stayed inside and temporary files deleted in a different layer (with the null effect you demonstrated in lesson 02-03).

--no-trunc: seeing the full command

By default the CREATED BY column is cut off. To audit properly, you need the whole text:

docker image history node:22-alpine --no-trunc --format "{{.CreatedBy}}" | head -3

This is the command with which you spot a suspicious curl … | sh in somebody else's image, the installation of a tool you were not expecting or, in your own image before publishing it, a --build-arg with a password like the one in lesson 02-04.

An honest limitation: history shows the instructions, not the content. A file copied with COPY does not show up here. For that, start the image and look (docker run --rm image ls -la /app) or use third-party tools such as dive (lesson 07-04).

  1. Deleting images: docker image rm

docker image rm auroralibros/aurora-api:0.1.0
Untagged: auroralibros/aurora-api:0.1.0
Deleted: sha256:6b4d2f8e1a3c...
Deleted: sha256:9a2f7c1e5b8d...

docker rmi is the short alias. Look at the output: Untagged and Deleted are two different things, and understanding the difference is the key to this whole section.

Deleting one of several tags

When two tags point at the same image (the same IMAGE ID), deleting one does not delete the image:

docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:stable
docker image ls auroralibros/aurora-api
REPOSITORY                TAG       IMAGE ID       SIZE
auroralibros/aurora-api   1.1.0     4e9c7d2a8f31   167MB
auroralibros/aurora-api   stable    4e9c7d2a8f31   167MB

The same IMAGE ID: it is one image with two names, not two 167 MB images. Now delete one:

docker image rm auroralibros/aurora-api:stable
Untagged: auroralibros/aurora-api:stable

Only Untagged, with no Deleted at all. The name has been removed; the data is still there because 1.1.0 still references it. Deleted only appears when the last reference disappears. This is why you sometimes delete an image and do not get a single byte of disk back.

Deleting by ID and by digest

# By ID (an unambiguous prefix is enough)
docker image rm 4e9c7d2a

# By digest, after publishing the image
docker image rm node@sha256:9f2c1a5e7b04...

Deleting by ID when several tags point at that image fails:

Error response from daemon: conflict: unable to delete 4e9c7d2a8f31 (must be forced)
- image is referenced in multiple repositories

Docker refuses because it does not know which name you want to remove. Either you delete each tag by its name, or you use -f to remove them all at once.

The "image in use" error

The most frequent of all:

docker run -d --name aurora-demo auroralibros/aurora-api:1.1.0
docker stop aurora-demo
docker image rm auroralibros/aurora-api:1.1.0
Error response from daemon: conflict: unable to remove repository reference
"auroralibros/aurora-api:1.1.0" (must force) - container 3f8a1c9b7e2d is using its
referenced image 4e9c7d2a8f31

The container is stopped, not running, and it still blocks the deletion. It is entirely logical: as you learned in lesson 01-05, a stopped container keeps its writable layer, which is stacked on top of the image's layers. Deleting the image would leave that layer floating on nothing, and the container would not be able to start again.

The three possible ways out:

# a) Find the containers using it
docker ps -a --filter ancestor=auroralibros/aurora-api:1.1.0

# b) Delete the container and then the image — THE CORRECT WAY
docker rm aurora-demo
docker image rm auroralibros/aurora-api:1.1.0

# c) Force it
docker image rm -f auroralibros/aurora-api:1.1.0

When is -f acceptable? Less often than it is used:

Situation -f? Alternative
A stopped container you no longer need Acceptable Better to docker rm first: more explicit
Several tags pointing at the same image and you want to delete them all Yes This is the legitimate use
A running container No Stop it first, through its orderly lifecycle
You do not know why it is failing No Investigate with docker ps -a --filter ancestor=…

The danger of -f on a running container: the image is "deleted" from the listing but its layers keep taking up disk as long as the container lives, and the container will not be able to restart once it is stopped. You end up with a service that works until its first restart and is then unrecoverable, with no way to rebuild it other than the registry. It is a classic production incident.

Bulk deletion

# Every image in a repository
docker image rm $(docker image ls -q auroralibros/aurora-api)

# ALL images (careful!)
docker image rm -f $(docker image ls -aq)

The $(docker image ls -q …) pattern combines -q (IDs only) with the shell's command substitution. Before running a bulk deletion, run just the inner part first to see what you are about to destroy:

docker image ls auroralibros/aurora-api    # See what is there
docker image rm $(docker image ls -q auroralibros/aurora-api)   # And then delete

  1. Dangling images: the <none>:<none> ones

Sooner or later you will see this:

REPOSITORY                TAG       IMAGE ID       CREATED          SIZE
<none>                    <none>    2f8e1a9c4b73   5 minutes ago    167MB
<none>                    <none>    7d3c9f2e8a51   22 minutes ago   167MB
auroralibros/aurora-api   1.1.0     4e9c7d2a8f31   30 minutes ago   167MB

Those <none>:<none> entries are dangling images: real, complete and perfectly functional images that have lost their name.

Where they come from

The main cause, by far, is rebuilding with the same tag:

cd ~/aurora-libros/api
docker build -t auroralibros/aurora-api:1.1.0 .    # Image A ← 1.1.0
echo "// a change" >> server.js
docker build -t auroralibros/aurora-api:1.1.0 .    # Image B ← 1.1.0

On the second build a new image is born (different content, different ID) and the 1.1.0 tag moves to it. Image A is not deleted: it is left without a name. It is exactly the model from lesson 02-01 — tags are movable pointers to immutable digests — seen from the local side.

Check it:

docker image ls --filter "dangling=true"
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
<none>       <none>    2f8e1a9c4b73   2 minutes ago   167MB

The other two sources:

  • Builds without -t. docker build . produces a nameless image from the very first second. That is the reason for the tip in lesson 02-02: always tag.
  • Failed builds that left intermediate layers behind.

Why they matter

Because they pile up silently. A developer who rebuilds twenty times a day with the same tag generates twenty dangling images daily. Most of them share layers and do not take up 167 MB each, but the layers of their own (the COPY . . and sometimes the RUN npm ci) are exclusive. In a few weeks that is several gigabytes, and the no space left on device message arrives at the worst possible moment.

They are not absolute garbage: if you know their ID, you can run them and even retag them.

docker image tag 2f8e1a9c4b73 recovered:1.0

That saves the day when you accidentally delete the tag of an image you had not published yet. But as a matter of routine, they get cleaned up.

  1. Cleaning up with the prune family

Four commands, with four very different blast radii. This table is the one to memorize:

Command What it deletes Risk
docker image prune Only dangling images Low: safe almost always
docker image prune -a Every image with no container using it Medium: forces you to re-download base images
docker builder prune The BuildKit cache Medium: subsequent builds will be slow
docker system prune Stopped containers + unused networks + dangling images + build cache Medium-high
docker system prune -a --volumes All of the above + every image with no container + volumes VERY HIGH: it destroys data

docker image prune

docker image prune
WARNING! This will remove all dangling images.
Are you sure you want to continue? [y/N] y
Deleted Images:
deleted: sha256:2f8e1a9c4b73...
deleted: sha256:7d3c9f2e8a51...

Total reclaimed space: 51.3MB

This is the one you can run with confidence: it only takes away nameless images. With -f it skips the confirmation (useful in scripts) and with --filter "until=168h" it limits the deletion to those older than a week:

docker image prune -f --filter "until=168h"

docker image prune -a

docker image prune -a
WARNING! This will remove all images without at least one container associated to them.
Are you sure you want to continue? [y/N]

Read the warning carefully: it deletes every image that has no associated container, not just the dangling ones. If you have no containers created — which is normal after cleaning up — this takes away node:22-alpine, postgres:16-alpine, redis:7-alpine, your local aurora-api images and everything else. It is not catastrophic (things can be rebuilt and re-downloaded), but it means gigabytes of downloads and, if you are offline or close to the pull limit from lesson 02-01, a bad time.

docker builder prune

BuildKit's cache is invisible in docker image ls and is usually the bulkiest thing on the machine:

docker builder prune
WARNING! This will remove all dangling build cache. Are you sure you want to continue? [y/N] y
Total:  2.847GB

Almost three gigabytes that did not appear in any image listing. Variants:

docker builder prune -a                      # The in-use cache too
docker builder prune --filter "until=72h"    # Only what is older than 3 days
docker builder prune --keep-storage=10GB     # Keeps up to 10 GB of cache

The trade-off: the next build of each project will run cold. Remember from lesson 02-02 that this is precisely where the cache does not help.

docker system prune

docker system prune
WARNING! This will remove:
  - all stopped containers
  - all networks not used by at least one container
  - all dangling images
  - unused build cache

Are you sure you want to continue? [y/N] y

Deleted Containers:
3f8a1c9b7e2d...
Deleted Networks:
aurora-test-net
Deleted Images:
deleted: sha256:2f8e1a9c4b73...
Total reclaimed space: 3.12GB

The warning lists exactly what it is going to delete. Always read it: it is the difference between cleaning up and losing work. What it takes away by surprise is usually a stopped container that held data in its writable layer, or a network you created by hand for some testing.

docker system prune -a --volumes: the dangerous command

docker system prune -a --volumes
WARNING! This will remove:
  - all stopped containers
  - all networks not used by at least one container
  - all volumes not used by at least one container
  - all images without at least one container associated to them
  - all build cache

This is the line that ruins your day: all volumes not used by at least one container.

Translated to Aurora Libros: when in module 3 you have a volume with the PostgreSQL catalog and you have stopped and deleted the aurora-db container in order to recreate it, that volume is temporarily left with no associated container. A docker system prune -a --volumes at that moment deletes the entire database. No recycle bin, no undo, no recovery.

Rules of use:

  • Never on a server. Never.
  • In development, only when you are certain there is no volume you care about.
  • Before running it, look at which volumes exist:
docker volume ls
docker system df -v | head -20
  • If all you want is space, the safe ladder is: docker image prunedocker builder prunedocker system prune. You rarely need to reach the last rung.

  1. Diagnosing disk space: docker system df

Before deleting anything, measure.

docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          9         2         842.3MB   601.7MB (71%)
Containers      4         1         12.4MB    9.1MB (73%)
Local Volumes   3         1         248.9MB   187.2MB (75%)
Build Cache     47        0         2.847GB   2.847GB (100%)

How to read it:

Column Meaning
TOTAL Number of objects of that type
ACTIVE Those in use (images with a container, mounted volumes…)
SIZE Real disk space, with shared layers already discounted
RECLAIMABLE How much you would free by cleaning up, and what percentage of the total that is

Two immediate conclusions from this example:

  1. Nine images take up 842 MB, not the sum of their SIZE columns in docker image ls (which would come to more than 1.2 GB). The difference is the shared layers from lesson 01-05, counted only once.
  2. The build cache is 2.85 GB, 100% reclaimable, and it is by far the biggest thing there. It is the first thing to clean up, and it did not appear in any image listing.

The detail with -v

docker system df -v
Images space usage:

REPOSITORY                TAG         IMAGE ID       SIZE      SHARED SIZE   UNIQUE SIZE   CONTAINERS
auroralibros/aurora-api   1.1.0       4e9c7d2a8f31   167MB     142MB         24.8MB        1
auroralibros/aurora-api   1.0.0       8c1e4a7f2b9d   167MB     142MB         24.7MB        0
node                      22-alpine   9f2c1a5e7b04   142MB     142MB         0B            0
postgres                  16-alpine   b71c3d8f4a29   278MB     8.17MB        270MB         0

Containers space usage:

CONTAINER ID   IMAGE                           SIZE      STATUS
3f8a1c9b7e2d   auroralibros/aurora-api:1.1.0   4.2MB     Up 12 minutes

Local Volumes space usage:

VOLUME NAME                                   LINKS     SIZE
aurora-db-data                                1         187.2MB
f3a9c2e1b8d7460a2c5f8e1d4b7a3c9e2f6d8a1b     0         61.7MB

The SHARED SIZE and UNIQUE SIZE columns are what clear everything up:

  • node:22-alpine: 142 MB in size, 142 MB shared, 0 B unique. Deleting it would free nothing, because all its layers are used by your aurora-api images.
  • aurora-api:1.0.0: 167 MB, of which only 24.7 MB are exclusive. That is what you would gain by deleting it.
  • postgres:16-alpine: 278 MB with 270 MB unique. This one is a good candidate if you are not going to use it.

And look at the last volume line: f3a9c2e1b8d7… with 0 links is an orphaned anonymous volume, exactly what lesson 02-04 anticipated when it advised against VOLUME in a Dockerfile. It takes up 61.7 MB and nobody knows what is in it.

  1. Moving images without a registry: save/load and export/import

Sometimes you have to take an image to another machine with no registry in between: a client with an air-gapped network, an environment with no internet access, a demo on a laptop with no connection. Docker offers two pairs of commands that get confused constantly and that are not interchangeable.

Aspect save / load export / import
Operates on An image A container
What it saves All the layers + manifest + metadata The flattened filesystem, with no layers
Preserves the history Yes No
Preserves CMD, ENTRYPOINT, ENV, USER, EXPOSE Yes No
Preserves image tags Yes No (you have to retag)
Several images in one file Yes No
Size of the .tar Larger (all the layers) Smaller (a single level)
Correct use Moving images Extracting a filesystem, flattening

The rule: to move an image, always save/load. export/import is for something else.

docker save / docker load with Aurora Libros

# 1. Export the image to a tar file
docker save -o aurora-api-1.1.0.tar auroralibros/aurora-api:1.1.0
ls -lh aurora-api-1.1.0.tar
-rw------- 1 joan joan 168M Aug  4 12:14 aurora-api-1.1.0.tar
# 2. Compress it: the layers are mostly text and binaries, and they compress well
gzip -9 aurora-api-1.1.0.tar
ls -lh aurora-api-1.1.0.tar.gz
-rw------- 1 joan joan 62M Aug  4 12:15 aurora-api-1.1.0.tar.gz

From 168 MB to 62 MB: 63% less, and that is what travels on the USB stick or over SCP. You can also do it in a single step with a pipe:

docker save auroralibros/aurora-api:1.1.0 | gzip -9 > aurora-api-1.1.0.tar.gz
# 3. Transfer it to the other machine
scp aurora-api-1.1.0.tar.gz operator@aurora-server:/tmp/

# 4. Load it there
ssh operator@aurora-server
gunzip -c /tmp/aurora-api-1.1.0.tar.gz | docker load
Loaded image: auroralibros/aurora-api:1.1.0
# 5. Verify it arrived complete
docker image ls auroralibros/aurora-api
docker image inspect auroralibros/aurora-api:1.1.0 --format '{{json .Config.Entrypoint}} · {{.Config.User}}'
docker run -d --name aurora-ported -p 3000:3000 auroralibros/aurora-api:1.1.0
curl -s http://localhost:3000/health
REPOSITORY                TAG     IMAGE ID       CREATED          SIZE
auroralibros/aurora-api   1.1.0   4e9c7d2a8f31   45 minutes ago   167MB

["node"] · node

{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko",...}

The same IMAGE ID, the same ENTRYPOINT, the same USER. The image has arrived intact, with all the metadata you defined in lesson 02-04.

Saving several images into a single file, useful for taking the entire Aurora Libros stack with you:

docker save -o aurora-stack.tar \
  auroralibros/aurora-api:1.1.0 \
  postgres:16-alpine \
  redis:7-alpine \
  nginx:alpine
ls -lh aurora-stack.tar
-rw------- 1 joan joan 512M Aug  4 12:22 aurora-stack.tar

A single file with everything needed to bring the platform up on a machine with no internet.

docker export / docker import

It works on containers, not images, and it flattens the result:

docker run -d --name to-export auroralibros/aurora-api:1.1.0
docker export -o aurora-flat.tar to-export
ls -lh aurora-flat.tar
-rw------- 1 joan joan 158M Aug  4 12:25 aurora-flat.tar
docker import aurora-flat.tar aurora-flat:1.0
docker image history aurora-flat:1.0
IMAGE          CREATED         CREATED BY   SIZE      COMMENT
5c9e2f7a1b83   4 seconds ago                158MB     Imported from -

A single layer and no history at all. The whole genealogy has vanished. And the serious part:

docker run --rm aurora-flat:1.0
docker: Error response from daemon: no command specified.

The ENTRYPOINT, the CMD, the ENV, the USER and the EXPOSE have all been lost. The imported image is a filesystem with no instructions. You would have to supply them by hand at runtime:

docker run --rm -u node -e NODE_ENV=production -w /app aurora-flat:1.0 node server.js

So what is it good for? For two legitimate things:

  1. Flattening an image with too many layers or with a secret buried in an intermediate layer that you want to remove for real. When you flatten, that layer disappears. (The modern and better way to achieve this is multi-stage builds, lesson 05-04.)
  2. Extracting the filesystem of a container to analyze it forensically or to build a base image from an existing system.

You can restore the metadata at import time, with -c:

docker import \
  -c 'ENTRYPOINT ["node"]' \
  -c 'CMD ["server.js"]' \
  -c 'WORKDIR /app' \
  -c 'USER node' \
  -c 'ENV NODE_ENV=production PORT=3000' \
  -c 'EXPOSE 3000' \
  aurora-flat.tar aurora-flat:1.1
docker run -d --name flat-ok -p 3001:3000 aurora-flat:1.1
curl -s http://localhost:3001/health | head -c 60
{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko"

It works, but you have had to rebuild by hand what save preserved on its own. It is clear which is the right tool for moving images.

Cleanup:

docker rm -f to-export flat-ok aurora-ported 2>/dev/null
docker image rm aurora-flat:1.0 aurora-flat:1.1 2>/dev/null
rm -f aurora-flat.tar

  1. A recommended maintenance routine

With all of the above, this is a reasonable routine for a development machine.

Weekly (safe, can be automated):

docker image prune -f
docker builder prune -f --filter "until=168h"
docker system df

It deletes dangling images and build cache older than a week, and shows how the space looks afterwards. It touches nothing that has a name, no containers and no volumes.

Monthly (manual review):

docker system df -v | head -40                    # What takes up space and what is unique?
docker image ls --filter "before=$(date -d '30 days ago' +%Y-%m-%d)" 2>/dev/null
docker volume ls -f dangling=true                 # Orphaned volumes: LOOK at what they are
docker ps -a --filter status=exited               # Forgotten stopped containers

Nothing gets automated here: you look and you decide. Orphaned volumes have to be inspected before deleting them, because they may contain data.

Before an important build:

docker builder prune -f
docker build --no-cache --pull -t auroralibros/aurora-api:1.1.0 .

A clean cache and an up-to-date base, as explained in lesson 02-02.

When you are out of space, the safe ladder:

docker system df                                  # 1. Measure before touching anything
docker builder prune -f                           # 2. The biggest and the least risky
docker image prune -f                             # 3. The dangling ones
docker container prune -f                         # 4. Stopped containers (review them)
docker image prune -a --filter "until=720h"       # 5. Unused images older than 30 days

In five steps ordered from least to most risk you almost always recover several gigabytes without ever reaching docker system prune -a --volumes.

A maintenance script with a report:

#!/bin/bash
# docker-maintenance.sh — safe weekly cleanup
set -e

echo "=== Space BEFORE ==="
docker system df

echo "=== Cleaning dangling images ==="
docker image prune -f

echo "=== Cleaning build cache older than 7 days ==="
docker builder prune -f --filter "until=168h"

echo "=== Space AFTER ==="
docker system df

echo "=== Orphaned volumes (REVIEW BY HAND, not deleted) ==="
docker volume ls -f dangling=true

Notice the last section: it lists the orphaned volumes but does not delete them. That is the line that separates a maintenance script from a data-destruction script.

Common Mistakes and Tips

  • Adding up the SIZE column of docker image ls. It is not disk space: shared layers are counted in every row. The real figure is in docker system df, and the breakdown in docker system df -v.
  • Deleting a tag and expecting to recover space. If the output only says Untagged and not Deleted, you have freed nothing: the image has another reference.
  • docker image rm -f on a running container. The image "disappears" but its layers stay on disk and the container will never be able to restart. Stop the container first.
  • docker system prune -a --volumes without reading the warning. It deletes volumes with no associated container, and a database volume between container recreations is in exactly that state. Never on a server.
  • Ignoring the BuildKit cache. It does not show up in docker image ls and it is usually the bulkiest thing on the machine. docker system df gives it away.
  • Confusing save/load with export/import. export loses ENTRYPOINT, CMD, ENV, USER and the history, and the imported image gives no command specified. To move images, always save/load.
  • Forgetting to compress the .tar. A gzip -9 usually cuts between 50% and 65%.
  • Tip: audit the variables before publishing. docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' should be a reflex before any docker push.
  • Tip: use docker image history … | sort -rh to hunt down fat layers. It is the "why is this image so big" diagnosis in a single command.
  • Tip: always tag with -t. Every untagged build is a dangling image from the very first second.
  • Tip: before a bulk deletion, run just the inner part of the $( … ) to see what you are about to destroy.

Exercises

Exercise 1: audit the real disk usage of your machine

  1. Run docker system df and note the total and the reclaimable percentage of each category.
  2. With docker system df -v, identify the image with the largest UNIQUE SIZE and the one with a UNIQUE SIZE of 0 B. Explain what each case means and how much deleting each one would free.
  3. Use docker image history to locate the heaviest layer of auroralibros/aurora-api:1.1.0 and of postgres:16-alpine.
  4. Check whether you have orphaned volumes and work out, without deleting them, which image they came from.

Exercise 2: demonstrate the lifecycle of tags and dangling images

  1. Build auroralibros/aurora-api:experiment from your Dockerfile and note the IMAGE ID.
  2. Create a second tag, auroralibros/aurora-api:copy, pointing at the same image. Check that the ID matches and that docker system df has not grown.
  3. Delete the copy tag. Does it say Untagged, Deleted or both? Why?
  4. Modify server.js, rebuild with the same experiment tag and check that a <none>:<none> image appears. Explain where it came from.
  5. Recover that dangling image by retagging it as auroralibros/aurora-api:rescued and verify that it works.
  6. Clean up everything created in the exercise.

Exercise 3: move the Aurora Libros stack to a machine with no internet

Simulate a deployment in an air-gapped environment:

  1. Export auroralibros/aurora-api:1.1.0, postgres:16-alpine and redis:7-alpine into a single file.
  2. Compress it and note the size before and after.
  3. Delete the three images from your machine (simulating a blank target machine).
  4. Load them from the file and verify that aurora-api keeps its ENTRYPOINT, USER, ENV and HEALTHCHECK.
  5. Repeat the cycle with export/import on an aurora-api container and compare: tar size, number of layers in the history and behavior when running docker run with no arguments. Conclude which pair you would use and why.

Solutions

Solution to exercise 1

docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          9         2         842.3MB   601.7MB (71%)
Containers      4         1         12.4MB    9.1MB (73%)
Local Volumes   3         1         248.9MB   187.2MB (75%)
Build Cache     47        0         2.847GB   2.847GB (100%)

1. Approximate total: 3.95 GB, of which 3.65 GB are reclaimable. The build cache is 72% of everything and it is 100% reclaimable: it is the priority target.

2.

docker system df -v | head -15
  • Largest UNIQUE SIZE: postgres:16-alpine, with 270 MB unique out of its 278 MB. The only thing it shares with the rest is Alpine's base layer (8.17 MB). Deleting it would free 270 real MB.
  • UNIQUE SIZE of 0 B: node:22-alpine. Its 142 MB are entirely shared with your aurora-api images, which were built on top of it. Deleting it would not free a single byte as long as some derived image exists; it would only disappear from the listing. It is the practical demonstration of the shared layers from lesson 01-05.

3.

docker image history auroralibros/aurora-api:1.1.0 --format "{{.Size}}\t{{.CreatedBy}}" | sort -rh | head -3
docker image history postgres:16-alpine --format "{{.Size}}\t{{.CreatedBy}}" --no-trunc | sort -rh | head -3
24.7MB   RUN /bin/sh -c npm ci --omit=dev && npm cache clean --force # buildkit
8.17MB   /bin/sh -c #(nop) ADD file:1b8a2c9e... in /
7.82MB   RUN /bin/sh -c apk add --no-cache --virtual .build-deps ...

248MB    RUN /bin/sh -c set -eux; apk add --no-cache --virtual .build-deps ... ; make -C /usr/src/postgresql ...
21.4MB   RUN /bin/sh -c apk add --no-cache bash su-exec tzdata zstd
8.17MB   /bin/sh -c #(nop) ADD file:1b8a2c9e... in /

In aurora-api, the npm ci with 24.7 MB is the biggest contributor of its own: those are the production dependencies. In postgres, the 248 MB from compiling PostgreSQL explain its size on their own.

4.

docker volume ls -f dangling=true
docker volume inspect f3a9c2e1b8d7460a2c5f8e1d4b7a3c9e2f6d8a1b --format '{{.Mountpoint}} · created {{.CreatedAt}}'
sudo ls /var/lib/docker/volumes/f3a9c2e1b8d7460a2c5f8e1d4b7a3c9e2f6d8a1b/_data
PG_VERSION  base  global  pg_wal  postgresql.conf  ...

The content gives away its origin: it is an anonymous volume created by the PostgreSQL image, which declares VOLUME /var/lib/postgresql/data in its Dockerfile. It is exactly the scenario lesson 02-04 gave as a reason not to use VOLUME. Do not delete it without checking the content: it could be a database with real work inside.

Solution to exercise 2

cd ~/aurora-libros/api

# 1
docker build -q -t auroralibros/aurora-api:experiment .
docker image ls auroralibros/aurora-api:experiment --format "{{.ID}}"
4e9c7d2a8f31
# 2
docker system df --format "{{.Type}}: {{.Size}}" | head -1
docker image tag auroralibros/aurora-api:experiment auroralibros/aurora-api:copy
docker image ls auroralibros/aurora-api --format "table {{.Tag}}\t{{.ID}}\t{{.Size}}"
docker system df --format "{{.Type}}: {{.Size}}" | head -1
Images: 842.3MB
TAG           ID             SIZE
experiment    4e9c7d2a8f31   167MB
copy          4e9c7d2a8f31   167MB
Images: 842.3MB

The same ID and the same total space. docker image tag only creates a reference; it does not copy a single byte. It is the local demonstration of what you will see in the registry in lesson 02-06.

# 3
docker image rm auroralibros/aurora-api:copy
Untagged: auroralibros/aurora-api:copy

Only Untagged. There is no Deleted because the experiment tag still points at that image: the data is still referenced and the deletion is limited to removing the name.

# 4
echo "// a change to generate a dangling image" >> server.js
docker build -q -t auroralibros/aurora-api:experiment .
docker image ls --filter "dangling=true"
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
<none>       <none>    4e9c7d2a8f31   8 minutes ago    167MB

The ID 4e9c7d2a8f31 is the same as in step 1: the original image has not been deleted, it has lost its name. The new build produced a different image and the experiment tag moved to it. Tags are pointers, not owners.

# 5
docker image tag 4e9c7d2a8f31 auroralibros/aurora-api:rescued
docker run --rm auroralibros/aurora-api:rescued --version
docker image ls --filter "dangling=true"
v22.14.0
(no results)

Recovered and functional, and it no longer shows up as dangling because it has a name again. Notice that --version worked thanks to the ENTRYPOINT ["node"] from lesson 02-04.

# 6
docker image rm auroralibros/aurora-api:experiment auroralibros/aurora-api:rescued
docker image prune -f

Solution to exercise 3

# 1 and 2
cd /tmp
docker save -o aurora-stack.tar \
  auroralibros/aurora-api:1.1.0 postgres:16-alpine redis:7-alpine
ls -lh aurora-stack.tar
gzip -9 aurora-stack.tar
ls -lh aurora-stack.tar.gz
-rw------- 1 joan joan 481M Aug  4 12:40 aurora-stack.tar
-rw------- 1 joan joan 173M Aug  4 12:41 aurora-stack.tar.gz

From 481 MB to 173 MB: 64% less. Compressing is always worth it, especially if the file travels over SCP or on a USB stick.

# 3
docker image rm auroralibros/aurora-api:1.1.0 postgres:16-alpine redis:7-alpine
docker image ls | grep -E "aurora-api|postgres|redis"   # no results

# 4
gunzip -c aurora-stack.tar.gz | docker load
Loaded image: auroralibros/aurora-api:1.1.0
Loaded image: postgres:16-alpine
Loaded image: redis:7-alpine
docker image inspect auroralibros/aurora-api:1.1.0 --format \
'Entrypoint: {{json .Config.Entrypoint}}
Cmd:        {{json .Config.Cmd}}
User:       {{.Config.User}}
Env:        {{index .Config.Env 3}} {{index .Config.Env 4}}
Health:     {{json .Config.Healthcheck.Test}}
Revision:   {{index .Config.Labels "org.opencontainers.image.revision"}}'
Entrypoint: ["node"]
Cmd:        ["server.js"]
User:       node
Env:        NODE_ENV=production PORT=3000
Health:     ["CMD-SHELL","wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1"]
Revision:   7a3f912

Absolutely everything intact: entrypoint, cmd, user, variables, healthcheck and even the OCI labels with the commit hash.

# 5. Comparison with export/import
docker run -d --name compare auroralibros/aurora-api:1.1.0
docker export compare | gzip -9 > aurora-flat.tar.gz
ls -lh aurora-flat.tar.gz
gunzip -c aurora-flat.tar.gz | docker import - aurora-flat:test
docker image history aurora-flat:test
docker run --rm aurora-flat:test
-rw------- 1 joan joan 58M Aug  4 12:48 aurora-flat.tar.gz

IMAGE          CREATED         CREATED BY   SIZE      COMMENT
9d2e4f8a1c73   3 seconds ago                158MB     Imported from -

docker: Error response from daemon: no command specified.

The final comparison table:

Aspect save/load export/import
Compressed size (aurora-api only) ~62 MB ~58 MB
Layers preserved 7 1
History Complete None
ENTRYPOINT, CMD, USER, ENV Preserved Lost
HEALTHCHECK and OCI labels Preserved Lost
docker run with no arguments Starts the API no command specified
Several images per file Yes No

Conclusion: to move images, save/load, no argument. The small size saving from export does not make up for losing all the runtime configuration, the healthcheck and the traceability that took a whole lesson to build. export/import is a tool for flattening filesystems, not for moving images.

Cleanup:

docker rm -f compare
docker image rm aurora-flat:test
rm -f /tmp/aurora-stack.tar.gz /tmp/aurora-flat.tar.gz

Conclusion

You now know how to administer your image store. You list and filter with docker image ls, its --filters (including filtering by the OCI labels you added in 02-04) and its Go templates. You extract any specific piece of metadata with docker image inspect --format, and you have a new reflex worth keeping: auditing the environment variables before publishing any image. With docker image history you reconstruct how an image was made, layer by layer, and you find in a single command the one that ate the megabytes — in aurora-api, the npm ci with 24.7 MB; in postgres, the 248 MB of its compilation.

You know how to delete while understanding what is happening: Untagged removes a name and Deleted destroys data, and it only appears when the last reference falls. You know why a stopped container blocks the deletion of its image (it keeps its writable layer stacked on top) and when -f is acceptable and when it is an elegant way of breaking a service at its next restart. You understand where the <none>:<none> images come from: rebuilding with the same tag moves the pointer and orphans the previous image, which stays on disk and can even be rescued by retagging it.

You have mastered the cleanup ladder, from least to most risk — image prune, builder prune, system prune — with a warning burned in about the last rung: docker system prune -a --volumes deletes volumes with no associated container, and a database volume between container recreations is in exactly that state. You measure before touching anything with docker system df and its -v version, which reveals what no image listing shows: the BuildKit cache taking up gigabytes, the shared layers counted only once and the SHARED/UNIQUE columns that tell you how much you would really free by deleting each image. And you know how to take an image to an air-gapped machine with docker save/load, preserving entrypoint, user, healthcheck and OCI labels, as opposed to export/import, which flattens the filesystem and loses everything.

Your image is built, it is professional and you know how to maintain it. It is missing the one thing that justified the whole module: leaving your machine. In the final lesson, Tagging and Publishing Images, you will close the cycle. You will see that docker image tag copies nothing but creates references, you will decide a serious tagging strategy — semantic versioning with moving and fixed tags, by commit SHA, by branch, by environment — and you will publish auroralibros/aurora-api to Docker Hub and to GitHub Container Registry, reading the push output, verifying the result with docker manifest inspect and understanding why production deploys by digest and never by latest.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved