Right now you have three or four containers on your machine and you can still keep them all in your head. A month from now you will have thirty: the four Aurora Libros services, half a dozen forgotten experiments, containers from other projects and a pile of weeks-old Exited ones taking up disk and names. Without management tools, that docker ps -a turns into a wall of text you cannot read, and the mass docker rm you run at eleven at night takes down something you needed.

This is the day-to-day operations lesson. You are going to squeeze docker ps for everything it has —column by column, with its filters and its templates—, you are going to build a control panel for the four Aurora Libros services, you are going to learn to compose commands with -q to operate on dozens of containers without wrecking anything, to copy files between the host and a container with docker cp —which is how you will finally fill the books table with the eight titles—, to see with docker diff what a container has changed with respect to its image, and to understand why docker commit is a temptation you have to resist.

Contents

  1. docker ps: the seven columns, one by one
  2. Listing options: -a, -q, -s, -n, -l
  3. Filtering with --filter
  4. Formatting with --format and Go templates
  5. Labels: organizing the fleet with --label
  6. Composing commands with -q (and doing it sensibly)
  7. Deleting containers: rm, -f, -v and container prune
  8. Renaming and changing things live: rename and update
  9. docker cp: moving files in both directions
  10. docker diff: what has changed with respect to the image
  11. docker commit and why you should not use it
  12. The Aurora Libros control panel

  1. docker ps: the seven columns, one by one

docker ps
CONTAINER ID   IMAGE                COMMAND                  CREATED          STATUS                    PORTS                      NAMES
3f8a1c9e7b2d   postgres:16-alpine   "docker-entrypoint.s…"   35 minutes ago   Up 35 minutes             127.0.0.1:5432->5432/tcp   aurora-db
9d4b7e2f1a6c   redis:7-alpine       "docker-entrypoint.s…"   32 minutes ago   Up 4 minutes (healthy)    127.0.0.1:6379->6379/tcp   aurora-cache
Column What it is exactly Details worth knowing
CONTAINER ID The first 12 characters of the 64-character ID Typing the first few that are unique is enough: docker stop 3f8 works
IMAGE The image reference exactly as it was written If you started from a digest, you will see the digest; if the tag moved afterwards, this text no longer tells the truth
COMMAND The effective command: ENTRYPOINT + CMD It appears truncated with …. You see it whole with --no-trunc
CREATED When the container was created It is not when it was started: a container created a month ago and started a minute ago says "5 weeks ago"
STATUS State and time in it Up 35 minutes, Exited (0) 2 hours ago, Up 4 minutes (healthy), Created, Paused
PORTS Active publications 127.0.0.1:5432->5432/tcp is host→container. If it only says 5432/tcp, it is exposed but not published
NAMES The assigned or invented name Unique on the machine

Two observations about the output above, which double as a review:

  • aurora-cache says Up 4 minutes and not Up 32 minutes because you restarted it in the previous lesson: the STATUS counter resets to zero on every start, while CREATED does not move.
  • The (healthy) on aurora-cache does not appear by magic: the official Redis 7 image ships its own HEALTHCHECK. aurora-db shows nothing because the PostgreSQL image does not define one. Health is studied in depth in lesson 03-04.

To see the complete command:

docker ps --no-trunc --format "{{.Names}}: {{.Command}}"
aurora-db: "docker-entrypoint.sh postgres"
aurora-cache: "docker-entrypoint.sh redis-server"

  1. Listing options: -a, -q, -s, -n, -l

Option What it does Typical use
-a, --all Shows all of them, Exited and Created included Finding the container that died
-q, --quiet Only the IDs, one per line Composing with other commands
-s, --size Adds the SIZE column Knowing how much it really takes up
-n N The last N created, running or not docker ps -n 3
-l, --latest The last one created docker logs $(docker ps -lq)
--no-trunc Does not truncate IDs or commands Copying a full ID

-s: the real size of a container

docker ps -as --format "table {{.Names}}\t{{.Image}}\t{{.Size}}"
NAMES          IMAGE                SIZE
aurora-cache   redis:7-alpine       0B (virtual 41.2MB)
aurora-db      postgres:16-alpine   127kB (virtual 278MB)

This column has two numbers, and the difference between them is lesson 01-05 turned into a command:

  • The first (0B, 127kB) is the writable layer: what that container has written on top of the image. It is the only thing that belongs exclusively to it and the only thing lost when you delete it.
  • The virtual figure is the total size the container sees: its writable layer plus all the image's layers, which are shared and read-only.

Ten redis:7-alpine containers do not take up 412 MB, but 41.2 MB plus ten tiny writable layers. And aurora-db with its 127 kB of writes stands out: you have just created an entire database, so where is the data? In an anonymous volume that the official image declares, and which does not count as a writable layer. Hold on to that question: it is the heart of lesson 03-06.

  1. Filtering with --filter

--filter (or -f) accepts key=value pairs and can be repeated. The available filters:

Filter Example What it selects
status --filter status=exited By state: created, running, paused, restarting, exited, dead
name --filter name=aurora A name that contains that string (it is a substring, not an equality)
ancestor --filter ancestor=redis:7-alpine Containers created from that image
label --filter label=project=aurora-libros By label, with or without a value
exited --filter exited=1 Those that exited with that code
health --filter health=unhealthy By health state: starting, healthy, unhealthy, none
before / since --filter since=aurora-db Created before/after that container
id --filter id=3f8a1c9e7b2d By ID
volume --filter volume=aurora-data Those that mount that volume (lesson 03-06)
network --filter network=aurora-net Those connected to that network (lesson 03-05)
publish / expose --filter publish=5432 Those that publish or expose that port

Recipes ready to copy:

# Everything belonging to Aurora Libros, alive or dead
docker ps -a --filter name=aurora

# Only what is running from a specific image
docker ps --filter ancestor=postgres:16-alpine

# Containers that failed: they exited with a code other than 0
docker ps -a --filter status=exited --filter exited=1

# Containers that are sick right now
docker ps --filter health=unhealthy

# Everything created after aurora-db (useful for scoping a work session)
docker ps -a --filter since=aurora-db

A real example:

docker ps -a --filter status=exited --format "table {{.Names}}\t{{.Status}}"
NAMES          STATUS
api-shutdown   Exited (0) 18 minutes ago

Two warnings about how filters behave:

  • Several different --filter flags combine with logical AND. --filter status=running --filter name=aurora demands both conditions.
  • Several values of the same filter combine with logical OR. --filter status=exited --filter status=created returns those in either state.
  • name is a substring, not an equality. --filter name=aurora-db also finds aurora-db-copy. If you need an exact match, use an anchored expression: --filter name=^aurora-db$.

  1. Formatting with --format and Go templates

--format uses Go language templates, the same ones you already used with docker image ls in lesson 02-05.

Placeholder Content
{{.ID}} Short ID
{{.Names}} Name
{{.Image}} Image
{{.Command}} Effective command
{{.CreatedAt}} Full creation date
{{.RunningFor}} Elapsed time as text
{{.Status}} State with its time
{{.State}} Just the state (running, exited…)
{{.Ports}} Published ports
{{.Size}} Size (requires -s)
{{.Labels}} All the labels
{{.Label "key"}} The value of one label
{{.Mounts}} Mounted volumes
{{.Networks}} Connected networks

The word table at the beginning turns on the header and column alignment:

docker ps --format "table {{.Names}}\t{{.State}}\t{{.RunningFor}}"
NAMES          STATE     RUNNING FOR
aurora-db      running   41 minutes ago
aurora-cache   running   10 minutes ago

Without table, the output is free text, perfect for scripting:

docker ps --format "{{.Names}} -> {{.Image}}"
aurora-db -> postgres:16-alpine
aurora-cache -> redis:7-alpine

And there are two prebuilt formats that come in very handy:

docker ps --format json | head -1
docker ps --format "{{json .}}" | jq -r '.Names + " | " + .Status'
{"Command":"\"docker-entrypoint.s…\"","CreatedAt":"2026-08-04 19:28:41 +0200 CEST","ID":"3f8a1c9e7b2d",...}
aurora-db | Up 41 minutes
aurora-cache | Up 10 minutes

If you like a format, make it permanent in ~/.docker/config.json:

{
  "psFormat": "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
}

From that moment on, a plain docker ps uses your format. It is one of the tweaks that day-to-day use appreciates most.

  1. Labels: organizing the fleet with --label

When containers from three projects share the same machine, filtering by name stops being reliable. Labels are arbitrary metadata you assign when creating the container:

docker run -d --name label-test \
  --label project=aurora-libros \
  --label component=demo \
  --label environment=local \
  alpine:3.20 sleep 300

And then you filter by them with surgical precision:

docker ps --filter label=project=aurora-libros --format "{{.Names}}"
docker ps --filter label=component --format "{{.Names}}"
docker rm -f label-test
label-test
label-test

--filter label=key=value demands the exact value; --filter label=key only demands that the label exists.

Let's properly label the Aurora Libros fleet. Since labels, just like ports and variables, are frozen when the container is created and cannot be added afterwards (lesson 03-01), we have to recreate them. That is fine: aurora-db has no data to lose yet.

docker rm -f aurora-db aurora-cache

docker run -d --name aurora-db \
  --label project=aurora-libros \
  --label component=database \
  --label environment=local \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secret \
  -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  postgres:16-alpine

docker run -d --name aurora-cache \
  --label project=aurora-libros \
  --label component=cache \
  --label environment=local \
  -p 127.0.0.1:6379:6379 \
  redis:7-alpine
docker ps --filter label=project=aurora-libros \
  --format 'table {{.Names}}\t{{.Label "component"}}\t{{.Status}}'
NAMES          COMPONENT   STATUS
aurora-cache   cache       Up 5 seconds
aurora-db      database    Up 8 seconds

A labeling convention that works well and that we will use throughout the course:

Label Values in Aurora Libros What it is for
project aurora-libros Separating it from other projects on the machine
component database, cache, api, web Identifying each container's role
environment local, tests, production Distinguishing instances of the same service

With these three labels, operations like "stop everything from Aurora Libros in local without touching anything else" become trivial, and it is exactly what Docker Compose will do for you automatically in module 4: its com.docker.compose.project labels are this very mechanism.

  1. Composing commands with -q (and doing it sensibly)

docker ps -q prints only IDs, and that turns it into the input for any other command:

docker ps -q --filter ancestor=redis:7-alpine
9d4b7e2f1a6c

Recipes used daily:

# Stop every container from a specific image
docker stop $(docker ps -q --filter ancestor=postgres:16-alpine)

# Delete every stopped container
docker rm $(docker ps -aq --filter status=exited)

# Stop the entire Aurora Libros fleet
docker stop $(docker ps -q --filter label=project=aurora-libros)

# Restart only the sick ones
docker restart $(docker ps -q --filter health=unhealthy)

And now the three precautions, because these commands are sharp:

First: if the filter finds nothing, the command fails.

docker stop $(docker ps -q --filter name=does-not-exist)
"docker stop" requires at least 1 argument.

Annoying but harmless. You avoid it by checking first:

IDS=$(docker ps -q --filter label=project=aurora-libros)
[ -n "$IDS" ] && docker stop $IDS || echo "Nothing to stop"

Second: docker rm -f $(docker ps -aq) is the most dangerous command in this lesson. It deletes absolutely every container on the machine, including those from other projects, and without asking. If you need to clean up, do it scoped:

docker rm -f $(docker ps -aq --filter label=project=aurora-libros)

Third: look before you shoot. The golden rule is to run the listing-only version first:

# 1. What exactly am I about to touch?
docker ps -a --filter status=exited --format "table {{.Names}}\t{{.Status}}"

# 2. If the list is what I expected, then go ahead
docker rm $(docker ps -aq --filter status=exited)

That extra half-second has saved a lot of development databases.

  1. Deleting containers: rm, -f, -v and container prune

docker rm api-shutdown
api-shutdown

docker rm only works on stopped containers. With a running one:

docker rm aurora-cache
Error response from daemon: cannot remove container "aurora-cache":
container is running: stop the container before removing or force remove
Option Effect
(none) Only deletes stopped containers
-f, --force Sends SIGKILL and deletes. No grace period
-v, --volumes Also deletes the associated anonymous volumes (never the named ones)
-l, --link Removes a legacy link, not the container. Practically obsolete

About -f, an important nuance that connects with the previous lesson: docker rm -f is not docker stop + docker rm. It sends SIGKILL directly, with none of the ten seconds of grace. On a database, that is unplugging the server. The correct sequence when data is involved is:

docker stop --time 30 aurora-db && docker rm aurora-db

What exactly happens when you delete a container:

Destroyed Survives
The writable layer and everything that was in it The image it came from
The container's logs The named volumes (lesson 03-06)
Its configuration and its name The host files mounted as bind mounts
The anonymous volumes, only if you use -v The anonymous volumes, if you do not use -v (they are orphaned)

docker container prune

docker container prune
WARNING! This will remove all stopped containers.
Are you sure you want to continue? [y/N] y
Deleted Containers:
7d3f9a1b2c48e6015a2c9f7b3e1d8a4c6f0b2d9e7a5c3f1b8d6a4e2c9f7b5d3a

Total reclaimed space: 41.9kB

It deletes all stopped containers. It is worth repeating the warning from lesson 02-05: a stopped container may be something you were debugging or a development database you switched off yesterday. Scope it whenever you can:

# Only those stopped more than 24 hours ago
docker container prune --filter "until=24h"

# Only the stopped ones from a specific project
docker container prune --filter "label=project=aurora-libros"

# Without interactive confirmation (for scripts; use it with double care)
docker container prune -f --filter "until=168h"

  1. Renaming and changing things live: rename and update

You already saw docker rename in the previous lesson. Its companion is docker update, which can change things on a running container, though a very specific list of them:

docker update --restart=unless-stopped aurora-db
docker update --memory 512m --memory-swap 512m aurora-db
Can be changed with update Can never be changed
Memory (--memory, --memory-swap, --memory-reservation) Published ports
CPU (--cpus, --cpu-shares, --cpuset-cpus) Environment variables
Number of processes (--pids-limit) Mounts and volumes
Restart policy (--restart) Image, CMD or ENTRYPOINT
Disk I/O weight (--blkio-weight) Labels and networks

The detail of all those resource options and of the restart policies is the content of lesson 03-07; what matters here is remembering the boundary: update touches what lives in the cgroups; everything that lives in the namespaces (network, mounts, environment) is frozen when the container is created.

  1. docker cp: moving files in both directions

docker cp <source> <destination>

One of the two sides carries the container: prefix and the other is a host path. It works in both directions and also with stopped containers.

From the host to the container: loading the catalog

The time has come to fill the database. You have had ~/aurora-libros/db/init.sql since lesson 01-07, with the books table and the eight titles:

docker cp ~/aurora-libros/db/init.sql aurora-db:/tmp/init.sql
Successfully copied 3.07kB to aurora-db:/tmp/init.sql

And now run it inside the container:

docker exec aurora-db psql -U aurora -d aurora_books -f /tmp/init.sql
CREATE TABLE
CREATE INDEX
INSERT 0 8

Check the result:

docker exec aurora-db psql -U aurora -d aurora_books \
  -c "SELECT id, title, author, price FROM books ORDER BY id LIMIT 4;"
docker exec aurora-db psql -U aurora -d aurora_books -t \
  -c "SELECT COUNT(*) FROM books;"
 id |                 title                 |         author         | price
----+---------------------------------------+------------------------+--------
  1 | El jardín de senderos que se bifurcan | Jorge Luis Borges      |  14.50
  2 | Rayuela                               | Julio Cortázar         |  19.90
  3 | Cien años de soledad                  | Gabriel García Márquez |  17.95
  4 | La sombra del viento                  | Carlos Ruiz Zafón      |  21.00
(4 rows)

     8

The eight Aurora Libros books are in a real database, in a real container. It is the first time in the whole course that they exist outside a .sql file. Nobody can read them from the API yet —that comes in lesson 03-05— but they are there.

A note on method: loading the schema by hand with docker cp works but is not the right way, because you have to repeat it every time you recreate the container and nobody ever remembers to. In lesson 03-06 you will solve it properly, mounting init.sql into /docker-entrypoint-initdb.d/ so that PostgreSQL runs it by itself when it initializes.

From the container to the host: extracting a file

The reverse direction is just as easy and turns out to be essential when investigating an incident:

docker cp aurora-db:/var/lib/postgresql/data/pg_hba.conf ~/aurora-libros/pg_hba.conf.copy
head -3 ~/aurora-libros/pg_hba.conf.copy
Successfully copied 5.12kB to /home/junior/aurora-libros/pg_hba.conf.copy
# PostgreSQL Client Authentication Configuration File
# ===================================================

And a very common case: rescuing the log of an application that —against all good practice— writes to a file instead of to standard output.

docker cp aurora-api:/app/logs/error.log ./error-aurora-api.log

Details of how docker cp behaves that save you surprises:

Situation Result
docker cp file container:/path/ (ends with /) Copies into the directory
docker cp file container:/path (no /) If /path is a directory, it copies inside; if not, it creates or overwrites that file
docker cp folder/. container:/destination Copies the folder's contents
docker cp folder container:/destination Copies the whole folder inside the destination
With -a/--archive Preserves owner and group (UID/GID)
Without -a The files end up owned by root inside the container

That last point bites often: if you copy a file into a container running with USER node, the file will belong to root and the application may not be able to read it. And one clear limitation: docker cp cannot copy from one container to another directly; you have to go through the host.

  1. docker diff: what has changed with respect to the image

docker diff compares the container's current file system with that of the image it came from:

docker diff aurora-db | head -12
C /tmp
A /tmp/init.sql
C /run
A /run/postgresql
A /run/postgresql/.s.PGSQL.5432
C /var/lib/postgresql

Three letters and their meaning:

Letter Means Example
A Added: a new file or directory /tmp/init.sql, the one you just copied
C Changed: modified (or a directory whose contents changed) /tmp, because it now contains something new
D Deleted: removed with respect to the image A configuration file that was replaced

It is the visible manifestation of the copy-on-write from lesson 01-05: what you see here is exactly the contents of the writable layer, which is why docker diff and the SIZE column of docker ps -s are talking about the same thing.

And now the important observation:

docker diff aurora-db | grep "postgresql/data" | head -3

Not one line. You have just created a table and eight records, and PostgreSQL's data directory does not appear in the diff. The reason is that /var/lib/postgresql/data is not in the writable layer: the official image declares a VOLUME there, so Docker created an anonymous volume and mounted it at that path. And docker diff never looks inside mounts.

This is exactly what lesson 02-04 was anticipating when it talked about why VOLUME in a Dockerfile has side effects, and it is the doorway into lesson 03-06: there is data that lives neither in the image nor in the writable layer, but in a third place. A place that right now, for aurora-db, is anonymous and fragile.

A very practical use case for docker diff: finding out what an application you do not trust writes.

docker run -d --name diff-demo nginx:alpine
sleep 2 && curl -s localhost > /dev/null 2>&1
docker diff diff-demo
docker rm -f diff-demo
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
C /run
A /run/nginx.pid
C /var/cache/nginx
A /var/cache/nginx/client_temp

In ten lines you know that Nginx modifies its configuration at startup (its docker-entrypoint.sh does it), creates a PID file and several cache directories. That is invaluable information when you are about to make the file system read-only for security, something you will see in lesson 05-03.

  1. docker commit and why you should not use it

docker commit turns a container's current state into a new image:

docker commit --message "Catalog loaded by hand" aurora-db aurora-db:with-data
docker image ls aurora-db --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
sha256:8f2e9c1a7b34d6058e2f9a1c7b3d5e8a0c2f4b6d8e0a2c4f6b8d0e2a4c6f8b0d2
REPOSITORY:TAG        SIZE
aurora-db:with-data   278MB

It works. And that is precisely why it is dangerous. These are the reasons images are not built this way, picking up the whole of module 2:

Problem Consequence
It is not reproducible There is no file describing how that state was reached. If tomorrow you need the same image with Node 22.14, there is no way to regenerate it
It is not auditable docker image history will show one enormous layer with whatever comment you wrote, without saying what was installed, what was removed or where it came from
It is not versioned A Dockerfile lives in Git, gets reviewed in a pull request and has a history. A commit lives in the head of whoever made it
It drags in junk Temporary files, shell history, logs, credentials typed during the debugging session... everything gets baked into the image
It is heavier than it should be It is a monolithic layer that breaks the caching and layer sharing you took such care with in lesson 02-02
It breaks the chain of trust Nobody can answer "what is inside this?" without opening it up and looking

In the trade's vocabulary, an image like that is called a hand-crafted image or a snowflake, and it is the containerized equivalent of that server nobody dared reboot. It does have one legitimate use:

# Forensics: freeze a broken container BEFORE deleting it, to investigate at leisure
docker commit aurora-api aurora-api:forensic-2026-08-04
docker rm aurora-api
docker run --rm -it --entrypoint sh aurora-api:forensic-2026-08-04

Preserving the crime scene before cleaning it up. For that, yes, and for nothing else.

docker image rm aurora-db:with-data

  1. The Aurora Libros control panel

We close by putting everything from the lesson into a command you will use daily:

docker ps -a --filter label=project=aurora-libros \
  --format 'table {{.Label "component"}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}'
COMPONENT   NAMES          STATUS          PORTS
cache       aurora-cache   Up 22 minutes   127.0.0.1:6379->6379/tcp
database    aurora-db      Up 22 minutes   127.0.0.1:5432->5432/tcp

Turn it into a permanent alias in your ~/.bashrc or ~/.zshrc:

alias aurora='docker ps -a --filter label=project=aurora-libros --format "table {{.Label \"component\"}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}"'
alias aurora-stop='docker stop $(docker ps -q --filter label=project=aurora-libros)'
alias aurora-start='docker start $(docker ps -aq --filter label=project=aurora-libros)'

And a version with health and size, for when something goes wrong:

docker ps -as --filter label=project=aurora-libros \
  --format 'table {{.Names}}\t{{.Status}}\t{{.Size}}'
NAMES          STATUS          SIZE
aurora-cache   Up 23 minutes   0B (virtual 41.2MB)
aurora-db      Up 23 minutes   3.14kB (virtual 278MB)

Notice that aurora-db's writable layer has grown from 127 kB to 3.14 kB… hold on, it has gone down. What is happening is that the container is new: you recreated it when you added the labels, and those 3.14 kB are almost entirely the init.sql you copied with docker cp. The data of the eight books is not there, but in the anonymous volume we were talking about. One more reminder that there is a piece of the puzzle you have not placed yet.

Common Mistakes and Tips

  • Running docker rm -f $(docker ps -aq) "to clean up". It takes everything on the machine with it, including containers from other projects and development databases with weeks of data. Always filter by label or by name.
  • Trusting --filter name= as if it were an equality. It is a substring: name=aurora-db also finds aurora-db-backup. Anchor it with ^...$ when it matters.
  • Reading the CREATED column as "started ago". It is the creation date. For uptime, look at STATUS or {{.RunningFor}}.
  • Reading the virtual size as space used. Ten containers from the same image share its layers: the real disk usage is the sum of the writable layers plus one copy of the image.
  • Using docker rm -f on a database. It is a SIGKILL with no grace. First docker stop --time 30, then docker rm.
  • Forgetting -v when deleting and accumulating orphaned anonymous volumes. Every PostgreSQL container deleted without -v leaves its volume behind. You find them with docker volume ls -f dangling=true (lesson 03-06).
  • Copying files with docker cp into an unprivileged container and not understanding the permission denied. The file arrives as root. Use -a, or fix it afterwards with docker exec -u root ... chown.
  • Building images with docker commit. It works today and leaves you with no explanation tomorrow. The Dockerfile is the source of truth.
  • Tip: save your favorite formats in ~/.docker/config.json with psFormat. A readable docker ps by default is a daily gift.
  • Tip: before any mass operation, run the listing-only version. Look at the list, and only then swap ps for rm.

Exercises

Exercise 1: build your own control panel

Create five test containers with alpine:3.20 and sleep 300, labeled like this: two with project=aurora-libros and environment=local, two with project=aurora-libros and environment=tests, and one with project=other-client. Then write a single docker ps command that shows only the Aurora Libros ones in the tests environment, in a table with the columns name, environment and uptime. Finally, stop and delete in a single command exclusively the other-client ones, leaving the rest untouched, and prove with another command that the four Aurora Libros ones are still alive.

Exercise 2: investigate what a container writes (and where it does not write it)

Start a redis:7-alpine container called diff-redis. Before touching anything, run docker diff and note the result. Then write 100 keys with redis-cli, force a save to disk with redis-cli SAVE, and run docker diff again. Answer:

  1. Does the Redis data file appear in the docker diff? And in the SIZE column of docker ps -s?
  2. Find out with docker inspect --format '{{json .Mounts}}' where that file really is.
  3. Copy it to the host with docker cp and check its size with ls -lh.
  4. Delete the container with docker rm -f (without -v) and explain exactly what has happened to those 100 keys. Are they lost? Where are they?

Exercise 3: rescue and safe cleanup

Simulate the end of a chaotic working day:

  1. Create six containers that end immediately: three with code 0 (alpine:3.20 true) and three with code 1 (alpine:3.20 sh -c 'exit 1'), all with the label project=cleanup-tests.
  2. Write a command that lists only the ones that failed, with their name and their state.
  3. From one of the failed ones, extract the file /etc/os-release to the host with docker cp before deleting it.
  4. Delete in a single command only the ones that failed, leaving the three successful ones.
  5. Finally, clean up the rest with docker container prune scoped to the label, and verify that neither aurora-db nor aurora-cache has been affected.

Solutions

Solution to exercise 1

for n in 1 2; do
  docker run -d --name demo-local-$n \
    --label project=aurora-libros --label environment=local \
    alpine:3.20 sleep 300
done
for n in 1 2; do
  docker run -d --name demo-tests-$n \
    --label project=aurora-libros --label environment=tests \
    alpine:3.20 sleep 300
done
docker run -d --name demo-other --label project=other-client alpine:3.20 sleep 300

The panel command, with two filters combined with logical AND:

docker ps --filter label=project=aurora-libros --filter label=environment=tests \
  --format 'table {{.Names}}\t{{.Label "environment"}}\t{{.RunningFor}}'
NAMES           ENVIRONMENT   RUNNING FOR
demo-tests-2    tests         12 seconds ago
demo-tests-1    tests         14 seconds ago

The environment=local ones do not appear: they meet the first filter but not the second, and both must be met.

Surgical deletion of other-client:

# First LOOK
docker ps -a --filter label=project=other-client --format "{{.Names}}"
# Then ACT
docker rm -f $(docker ps -aq --filter label=project=other-client)
# And CHECK
docker ps --filter label=project=aurora-libros --format "{{.Names}}" | wc -l
demo-other
c8e1a4f7b209
4

The four Aurora Libros ones are still standing. The sequence look → act → check is what turns a dangerous command into a routine operation.

docker rm -f $(docker ps -aq --filter name=demo-)

Solution to exercise 2

docker run -d --name diff-redis redis:7-alpine
sleep 2
docker diff diff-redis
C /run
A /run/redis_6379.pid

Practically nothing: a freshly started Redis only writes its PID file. Now, load and save:

docker exec diff-redis sh -c 'for i in $(seq 1 100); do redis-cli SET book:$i "title-$i" > /dev/null; done'
docker exec diff-redis redis-cli DBSIZE
docker exec diff-redis redis-cli SAVE
docker exec diff-redis ls -lh /data
docker diff diff-redis
docker ps -s --filter name=diff-redis --format "{{.Names}}: {{.Size}}"
(integer) 100
OK
-rw-r--r--    1 redis    redis       3.4K Aug  4 20:41 dump.rdb
C /run
A /run/redis_6379.pid
diff-redis: 0B (virtual 41.2MB)

1. Here is the exercise's surprise: dump.rdb exists (you have just listed it with ls -lh, 3.4 kB) but it does not appear in the docker diff, and the writable layer still measures 0 B. The two tools agree with each other and both are telling the truth: that file is not in the container's writable layer.

2. The explanation is in the mounts:

docker inspect --format '{{json .Mounts}}' diff-redis | jq
[
  {
    "Type": "volume",
    "Name": "b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2",
    "Source": "/var/lib/docker/volumes/b41f7c9e2a8d.../_data",
    "Destination": "/data",
    "Driver": "local",
    "RW": true
  }
]

The official Redis image declares VOLUME /data, so Docker created an anonymous volume —that name that looks like a hash is exactly that: a volume with no name— and mounted it at /data. Everything Redis writes there goes to the volume, not to the writable layer, and that is why docker diff does not see it.

3.

docker cp diff-redis:/data/dump.rdb /tmp/dump-aurora.rdb
ls -lh /tmp/dump-aurora.rdb
Successfully copied 5.63kB to /tmp/dump-aurora.rdb
-rw-r--r-- 1 junior junior 3.4K Aug  4 20:42 /tmp/dump-aurora.rdb

docker cp does read through mounts: as far as it is concerned, /data/dump.rdb is simply a path in the container's file system. The 3.4 kB match the ls from inside; the 5.63 kB Docker reports is the size of the tar stream used to make the copy, with its headers and its block padding.

4.

docker rm -f diff-redis
docker volume ls -f dangling=true --format "{{.Name}}" | head -3
diff-redis
b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2

The correct answer is more interesting than a plain "they are lost":

  • The keys that were in memory died with the process, as in the previous lesson's exercise.
  • But the dump.rdb with the 100 keys still exists, in a volume that is now orphaned: with no container using it, with a name nobody remembers and taking up disk indefinitely. Since you used docker rm -f without -v, the volume was not deleted.
  • If tomorrow you start another redis:7-alpine, Docker will create a new, empty anonymous volume: it will not reuse the previous one. From a practical point of view, the data is lost even though the bytes are still on your disk.

That is exactly what is happening today to aurora-db with the eight books you just loaded, and it is the problem lesson 03-06 solves by giving the volume a name.

Solution to exercise 3

for n in 1 2 3; do
  docker run --name ok-$n --label project=cleanup-tests alpine:3.20 true
  docker run --name fail-$n --label project=cleanup-tests alpine:3.20 sh -c 'exit 1'
done

2. Listing only the ones that failed, combining status and exited:

docker ps -a --filter label=project=cleanup-tests --filter exited=1 \
  --format "table {{.Names}}\t{{.Status}}"
NAMES     STATUS
fail-3    Exited (1) 6 seconds ago
fail-2    Exited (1) 7 seconds ago
fail-1    Exited (1) 8 seconds ago

The exited=1 filter is what does the fine work: it distinguishes "it ended" from "it ended badly", something status=exited alone cannot do.

3. Rescuing a file from a stopped container:

docker cp fail-1:/etc/os-release /tmp/os-release-fail1.txt
head -2 /tmp/os-release-fail1.txt
Successfully copied 3.07kB to /tmp/os-release-fail1.txt
NAME="Alpine Linux"
ID=alpine

An important detail: docker cp works with the container stopped. There is no need to start it, and that makes it the ideal tool for performing autopsies on containers that no longer come up.

4. Selective deletion of the failed ones:

docker rm $(docker ps -aq --filter label=project=cleanup-tests --filter exited=1)
docker ps -a --filter label=project=cleanup-tests --format "{{.Names}} {{.Status}}"
2f8c1a9e4b73
7d1e5b2c8f04
9a3f7c1e5d28
ok-3 Exited (0) 1 minute ago
ok-2 Exited (0) 1 minute ago
ok-1 Exited (0) 1 minute ago

The three successful ones are still there. No -f was needed because they were already stopped.

5. Final scoped cleanup and verification:

docker container prune -f --filter "label=project=cleanup-tests"
docker ps -a --filter label=project=aurora-libros \
  --format 'table {{.Names}}\t{{.Status}}'
Deleted Containers:
1c7e9a3f5b28...
4b8d2f6a1c93...
6e0a4c8f2b17...

Total reclaimed space: 0B

NAMES          STATUS
aurora-cache   Up 41 minutes
aurora-db      Up 41 minutes

aurora-db and aurora-cache untouched, because the --filter label= scoped the prune to the right label. Compare mentally with what would have happened with a plain docker container prune -f: the three ok-* would have gone the same way, but so would any stopped container from any other project on your machine. Labels are not bureaucracy: they are the mechanism that makes cleanup selective instead of indiscriminate.

Conclusion

You no longer look at docker ps: you read it. You know what each of its seven columns says and, above all, what it does not say —that CREATED is not the start time, that IMAGE may have gone stale if the tag moved, and that the virtual number from -s is not space used but space seen—. You know how to filter by state, name, image, label, exit code and health, combining filters with AND and with OR, and you know how to shape the output with Go templates until it becomes an Aurora Libros control panel that fits in an alias.

You have learned to compose commands with -q and, more importantly, to do it with a safety net: look → act → check, always filtering by label so that a mass deletion never takes something that was not its business. You know the difference between docker rm and docker rm -f —which is not stop + rm, but a SIGKILL with no grace—, you know what is destroyed and what survives when a container is deleted, and you use docker container prune scoped with until and label instead of bare. You have labeled the fleet with project, component and environment, which is exactly the mechanism Docker Compose will automate for you in module 4.

With docker cp you move files in both directions, even with the container stopped, and that has got you something memorable: the eight Aurora Libros books are loaded into PostgreSQL, with their table, their index and their INSERT 0 8. With docker diff you know how to see the writable layer turned into a list of files —A, C and D— and you have discovered the missing piece: PostgreSQL's data directory does not appear in the diff, because it is not in the writable layer but in an anonymous volume that the image declares and nobody controls. And you know why docker commit is no good for building images, even though it is perfect for freezing a crime scene before cleaning it up.

You know how to watch the fleet when everything is fine. The other half is missing. In the next lesson, Inspecting and Debugging Containers, you will face the containers that do not work and do not say why: you will read docker logs in depth with its time filters and its golden rule about stdout, you will get in with docker exec even into minimal images that do not have so much as ping —using ephemeral containers that share their namespaces—, you will squeeze the JSON out of docker inspect with templates and jq, and you will watch live with docker top, docker stats and docker events. And you will apply all of it to three real Aurora Libros breakdowns, among them the one that has been waiting for two lessons: why aurora-api cannot find aurora-db even though both are running on the same machine.

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