Cheat Sheets

Docker Cheat Sheet: CLI and Compose Commands (PDF)

On a fresh Docker Engine install, docker images no longer prints the REPOSITORY and TAG table every Docker cheat sheet shows you. Your images aren’t under /var/lib/docker anymore either, because Engine 29 moved new installs to the containerd image store, and Compose jumped from v2 straight to v5. If your notes still say docker-compose up or version: "3.8", you’ll get a command-not-found error or a warning.

Original content from computingforgeeks.com - post 176999

This Docker cheat sheet covers the commands you use every day (images, containers, networks, volumes, builds, Docker Compose, cleanup) plus the firewall and SELinux behaviour that bites on servers. You get real output for each command, the exact errors near the end, and a one-page PDF. We tested everything here in October 2026 on Ubuntu 26.04 and Rocky Linux 10.2 with Docker Engine 29.9.2 and Compose v5.6.1 from Docker’s own repositories, and we call out each place the two distros behave differently.

Look up a Docker command by task

Start here when you know the job but not the flags. The sections below explain each of these with the output our lab hosts printed:

TaskCommand
Run a web server in the backgrounddocker run -d --name web -p 8080:80 nginx:1.29
List running containersdocker ps
List all containers, including stoppeddocker ps -a
Follow a container’s logsdocker logs -f --tail 50 web
Open a shell in a containerdocker exec -it web sh
Stop and remove a containerdocker rm -f web
List images (new 29.x view)docker images
Old image table with repository and tagdocker image ls --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}'
Build and tag an imagedocker build -t hello:1.0 .
Start a Compose stack and wait for healthdocker compose up -d --wait
Stop a Compose stack and delete its volumesdocker compose down -v
See what is using diskdocker system df
Remove unused images, containers, networks and cachedocker system prune -a

Check which Docker you are running

Ubuntu ships its own Docker packages, and they lag Docker’s repository by several releases, so check what you have before you trust any cheat sheet. Ubuntu 26.04 offered these:

apt-cache policy docker.io docker-compose-v2 | grep -E '^[a-z]|Installed|Candidate'

Both candidates are the distro builds, with Compose still on the 2.x line. The docker.io candidate stays the same after you add Docker’s repository, because that repository names its package docker-ce:

docker.io:
  Installed: (none)
  Candidate: 29.1.3-0ubuntu4.1
docker-compose-v2:
  Installed: (none)
  Candidate: 2.40.3+ds1-0ubuntu1

Rocky Linux has no Docker in its repositories at all and offers Podman instead. Once Engine is installed, print the client, the server and the runtime versions in one go:

docker version

Both hosts printed the same versions, because both installed from Docker’s repository on the same day. The server half is the one that decides behaviour, and its minimum API line tells you how old a client can be. API 1.40 is Docker 19.03, so a CI image with an older CLI fails against this daemon before it runs a single command:

Client: Docker Engine - Community
 Version:           29.9.2
 API version:       1.56
 Go version:        go1.26.10
 Git commit:        a82e528
 Built:             Tue Oct 20 18:45:12 2026
 OS/Arch:           linux/amd64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          29.9.2
  API version:      1.56 (minimum version 1.40)
  Go version:       go1.26.10
  Git commit:       0367cbe
  Built:            Tue Oct 20 18:45:12 2026
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          v2.4.2
  GitCommit:        b7a25261e029d68978bbf290df0a62fc819b3c51
 runc:
  Version:          1.5.2
  GitCommit:        v1.5.2-0-g29dd3dc2
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Compose and Buildx are CLI plugins with their own version numbers. Compose skipped v3 and v4 on purpose, because those numbers would have been confused with the old Compose file formats, so v5 is the release after v2:

docker compose version; docker buildx version

You should see the v5 Compose line and a current Buildx. If docker compose answers with a 2.x version on Ubuntu, you’re running the distro’s docker-compose-v2 package rather than Docker’s plugin:

Docker Compose version v5.6.1
github.com/docker/buildx v0.38.1 0dbe87f36ed472fe36b6796c3f70b4210a731fc5

Next, find out which image store the daemon uses. A fresh Engine 29 install uses the containerd store, while a host upgraded from an older Engine keeps the legacy overlay2 driver, and the two keep images in different places:

docker info -f '{{ .DriverStatus }}'

This value means the containerd store, which is what both fresh hosts reported. An upgraded host keeps the overlay2 driver instead, so its images stay under /var/lib/docker and the disk paths later on this page don’t apply to it:

[[driver-type io.containerd.snapshotter.v1]]

The full docker info is long, but four lines in it answer most “why does my host behave differently” questions. Here they are side by side from the two hosts:

docker info lineUbuntu 26.04Rocky Linux 10.2
Storage Driveroverlayfs, driver-type io.containerd.snapshotter.v1same
Cgroup Version2 (systemd driver)2 (systemd driver)
Security Optionsapparmor, seccomp, cgroupnsseccomp, cgroupns (no selinux)
Firewall Backendiptablesiptables+firewalld

The missing selinux entry on Rocky is not a mistake. Docker leaves SELinux labelling off unless you turn it on in daemon.json, even with the host in enforcing mode, and the SELinux section below shows what changes when you do.

Install Docker Engine on Ubuntu and Rocky Linux

Use Docker’s repository rather than the distro packages if you want the current Engine and Compose v5. Our Docker CE guide for Ubuntu 26.04 and Rocky Linux and AlmaLinux install guide cover every step with explanations. These are the short versions we ran in the lab. On Ubuntu, add the key and a deb822 .sources file, then install the five packages:

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
printf 'Types: deb\nURIs: https://download.docker.com/linux/ubuntu\nSuites: %s\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' "$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" "$(dpkg --print-architecture)" | sudo tee /etc/apt/sources.list.d/docker.sources
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

The Ubuntu package starts and enables Docker for you. Rocky Linux 10 still runs dnf 4, so the older --add-repo syntax is the right one there, and the RPM installs Docker without starting it:

sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker

dnf asks you to accept Docker’s signing key, and the fingerprint it shows should read 060A 61C5 1B55 8A7F 742B 77AA C52F EB6B 621E 9F35. Two things went wrong on Rocky in our lab, and both are common. If podman-docker is installed (it provides a fake docker command), the install stops with a conflict:

Error:
 Problem: problem with installed package podman-docker-7:5.8.2-9.el10_2.noarch
  - package podman-docker-7:5.8.2-9.el10_2.noarch from @System conflicts with docker-ce provided by docker-ce-3:29.9.2-1.el10.x86_64 from docker-ce-stable

Remove it with sudo dnf remove podman-docker and run the install again. On our host Podman had only arrived as a dependency of podman-docker, so dnf removed it too. If you installed podman on purpose it stays, and both engines can live side by side. The second problem only shows up when Docker starts, and it is covered under the addrtype error in the troubleshooting section, because it depends on which kernel the machine booted.

Docker commands talk to a root-owned socket, so add your user to the docker group and log in again (or run newgrp docker). Membership is effectively root on that host, so give it only to people who already have sudo:

sudo usermod -aG docker $USER

In a fresh SSH session, id -nG should list docker at the end, and docker ps should answer without sudo. Our Ubuntu user printed josphat adm cdrom sudo dip lxd docker.

Manage Docker images

Pull images with an explicit tag. latest moves every time upstream publishes, which makes a rebuild next month behave differently from today’s:

docker pull nginx:1.29
docker pull alpine:3.22
docker pull redis:8

Now list them. This is the layout Engine 29 prints by default, and few cheat sheets have caught up with it, so the columns you’re told to look for aren’t there:

docker images

There are no REPOSITORY or TAG columns. DISK USAGE is the unpacked size on disk, CONTENT SIZE is the compressed download, and the EXTRA column marks images in use with a U:

IMAGE         ID             DISK USAGE   CONTENT SIZE   EXTRA
alpine:3.22   2e0b81d60597       12.9MB         3.88MB
nginx:1.29    1881968aff6f        240MB         65.8MB
redis:8       97ec88159e6d        213MB         57.6MB        

Scripts that parse the old columns break on this view, because the first column now holds the name and tag together. Pass a --format and Docker falls back to the old table, so use one in anything automated and you won’t care when the default view changes again:

docker image ls --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}'

The classic columns come back, sorted differently from the new view:

REPOSITORY   TAG       IMAGE ID       SIZE
alpine       3.22      2e0b81d60597   12.9MB
redis        8         97ec88159e6d   213MB
nginx        1.29      1881968aff6f   240MB

Because the containerd store keeps the whole multi-platform index, docker image ls --tree shows every architecture an image offers and which one you actually downloaded. Only the linux/amd64 rows have a size here, so the other platforms are listed but not stored:

IMAGE                   ID             DISK USAGE   CONTENT SIZE   EXTRA
alpine:3.22             2e0b81d60597       12.9MB         3.88MB
├─ linux/amd64          dc92f017beea       12.8MB          3.8MB
├─ linux/arm/v6         178826c1dd59           0B             0B
├─ linux/arm/v7         2194dc1bfe94           0B             0B
├─ linux/arm64/v8       3e07a4f627d1           0B             0B
├─ linux/386            d4765993de94           0B             0B
├─ linux/ppc64le        380029807118           0B             0B
├─ linux/riscv64        f1741b288dd7           0B             0B
└─ linux/s390x          afba41359a0f           0B             0B        

Image data now lives under /var/lib/containerd, which matters when you size partitions or move Docker’s data root. After pulling those three images, sudo du -sh /var/lib/docker /var/lib/containerd reported 212K and 445M on Ubuntu (148K and 440M on Rocky). Setting data-root in daemon.json does not move containerd’s directory, so a big separate disk for /var/lib/docker no longer catches the images.

The rest of the image commands you’ll reach for:

JobCommand
Filter by namedocker image ls --filter reference='nginx*'
Show layers and the commands that made themdocker image history alpine:3.22
Show OS, architecture and full IDdocker image inspect -f '{{.Os}}/{{.Architecture}} {{.Id}}' nginx:1.29
Retag for a private registrydocker tag alpine:3.22 registry.example.com/team/alpine:3.22
Remove a tagdocker rmi registry.example.com/team/alpine:3.22
Export to a tar filedocker save -o /tmp/alpine.tar alpine:3.22
Import from a tar filedocker load -i /tmp/alpine.tar
Pull another architecturedocker pull --platform linux/arm64 alpine:3.22

The alpine export was a 3.8 MB tar file, and docker load answered Loaded image: alpine:3.22. Pulling an arm64 image works fine on an x86 host, but running it does not unless you have QEMU emulation registered. Our docker run --rm --platform linux/arm64 alpine:3.22 uname -m stopped with exec /bin/uname: exec format error and exit code 255 on both hosts.

Check your Docker Hub pull limit

Anonymous pulls from Docker Hub are rate limited per IP address, so the limit hits machines that share an address or start from an empty cache:

  • CI runners that pull fresh images on every job
  • offices and labs behind one NAT address
  • Kubernetes nodes pulling the same image at once

A HEAD request against Docker’s test repository shows your limit without spending a pull, so you can check the limit yourself from the machine that keeps failing:

TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
curl -s --head -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest | grep -i ratelimit

The first line names the address being counted, which we replaced with a documentation IP:

docker-ratelimit-source: 192.0.2.10
x-ratelimit-limit: 100;w=3600
ratelimit-limit: 100;w=3600
x-ratelimit-remaining: 83;w=3600
ratelimit-remaining: 83;w=3600

The w= value is the window in seconds. Docker’s pull limit documentation says 100 pulls per 6 hours for anonymous users and shows w=21600 in its example, but the registry answered 100;w=3600 from both lab hosts on October 23, 2026. Trust the header over any number you read, including ours, and log in with docker login on build machines, since a free personal account gets a higher limit.

Run and manage containers

docker run creates and starts a container in one step. These are the flags we use most, all tested on both hosts:

FlagWhat it doesExample
-dRun in the background and print the container IDdocker run -d nginx:1.29
--nameName the container so you don’t need IDs--name web
-p host:containerPublish a port on all host addresses-p 8080:80
-p 127.0.0.1:host:containerPublish on localhost only-p 127.0.0.1:8080:80
-e / --env-fileSet environment variables-e APP_ENV=prod
-v / --mountAttach a volume or bind mount-v appdata:/data
--rmDelete the container when it exitsdocker run --rm alpine:3.22 true
-w, -uWorking directory and user-w /srv -u 1000:1000
--restartRestart policy--restart unless-stopped
--memory, --cpusResource limits (cgroup v2)--memory 64m --cpus 0.5
--ulimitRaise limits such as open files--ulimit nofile=65536:65536

Start a container and check it, then use the format flag to keep docker ps readable in a narrow terminal:

docker run -d --name web -p 8080:80 nginx:1.29
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

The published port shows up twice, once for IPv4 and once for IPv6:

NAMES     STATUS         PORTS
web       Up 3 seconds   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp

The default open files limit inside a container is now 1024, because the containerd release bundled since Engine 29 no longer raises it, so docker run --rm alpine:3.22 sh -c 'ulimit -n' printed 1024 on both hosts and the same command with --ulimit nofile=65536:65536 printed 65536. Databases and proxies that open many sockets need that flag or default-ulimits in daemon.json, so add it before you move them onto a current Engine.

Memory limits are enforced by the kernel OOM killer: a container started with --memory 16m that ran tail /dev/zero died with exit code 137 and no message at all, so treat 137 as “check the memory limit”.

Inspect, debug and copy files

These answer “what is this container doing” without opening a shell. Each one is a single command:

JobCommand
Last lines of the logdocker logs --tail 3 web
Logs from the last 5 minutes, with timestampsdocker logs --since 5m -t web
Run a command insidedocker exec web nginx -v
Interactive shelldocker exec -it web sh
One field from the configdocker inspect -f '{{.State.Status}} {{.NetworkSettings.Networks.bridge.IPAddress}}' web
Port mappingsdocker port web
Processes insidedocker top web
CPU and memory, oncedocker stats --no-stream web
Copy a file outdocker cp web:/etc/nginx/nginx.conf /tmp/nginx.conf
Change limits on a running containerdocker update --restart unless-stopped --memory 256m --memory-swap 256m web
Watch eventsdocker events --since 2m --filter type=container

docker logs replays the container’s stderr on your stderr, so a pipe sees only half the log. Our test container wrote one line to each stream, and docker logs lg | grep err found nothing until we added 2>&1. The same goes for nginx, which writes its startup notices to stderr. docker top also maps container UIDs to host user names: on Ubuntu the nginx workers (UID 101) showed as uuidd, and on Rocky as plain 101, which looks alarming but is the same process.

Stop, restart and remove

The lifecycle commands accept several names at once, and rm -f stops and removes in one step:

docker stop web
docker start web
docker restart -t 2 web
docker kill -s HUP web
docker rename web web1
docker rm -f web1

A plain docker rm on a running container is refused with cannot remove container "web1": container is running: stop the container before removing or force remove. A container started with --restart on-failure:3 that kept exiting with code 1 reached RestartCount 3 within eight seconds, after which Docker stops retrying, so the limit runs out much faster than people expect.

Connect containers with Docker networks

Create a user-defined network for anything that talks to anything else. Containers on it find each other by name through Docker’s DNS, which the default bridge network does not do. We proved it with Redis:

docker network create appnet
docker run -d --name db --network appnet redis:8
docker run --rm --network appnet redis:8 redis-cli -h db ping

The client on the same network reached the server by its container name, because Docker runs a DNS server for every user-defined network. That’s also why Compose creates a network for each project, so your services find each other as web and cache without any links:

PONG

Run the same client without --network appnet and it lands on the default bridge, where the name means nothing:

Could not connect to Redis at db:6379: Name or service not known

The other network commands are short. docker network ls lists the networks, docker network connect bridge db adds a second network to a running container, and docker network inspect with a format string prints the members and subnet:

docker network inspect -f '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{end}} subnet={{(index .IPAM.Config 0).Subnet}}' appnet

Docker picked the next free range after the default bridge’s 172.17.0.0/16. If your LAN or VPN uses 172.16 to 172.31 addresses, you’ll want to set default-address-pools in daemon.json before you create many networks, because an overlap makes those hosts unreachable from the containers:

db 172.18.0.2/16 subnet=172.18.0.0/16

A network with containers attached can’t be removed. docker network rm appnet failed with network appnet has active endpoints (name:"db" id:"a8c7905aac21"), and docker rm -f db && docker network rm appnet cleared both.

Persist data with volumes and bind mounts

Use a named volume for data that Docker manages (databases, uploads) and a bind mount when you need to edit the files from the host. A named volume survives the container and lives under Docker’s root directory:

docker volume create appdata && docker volume inspect -f '{{.Mountpoint}}' appdata

The volume got the usual path even with the containerd image store, because volumes stay under /var/lib/docker. So a backup plan that covers that directory still catches your volume data, but it no longer catches the images:

appdata
/var/lib/docker/volumes/appdata/_data

Append :ro to mount it read-only. A write through -v appdata:/data:ro failed inside the container with sh: can't create /data/g: Read-only file system. To back up a volume, mount it next to a host directory and tar it from a throwaway container:

docker run --rm -v appdata:/data -v "$PWD":/backup alpine:3.22 tar czf /backup/appdata.tgz -C /data .

Bind mounts have one difference that matters. With -v /nonexistent-auto:/x Docker silently created the missing host directory as root, while --mount type=bind,src=/nonexistent,dst=/x refused with bind source path does not exist: /nonexistent. Use --mount in scripts so a typo in a path fails loudly instead of mounting an empty directory.

Build images with BuildKit

The docker build command runs BuildKit (Buildx is the client), so the output, caching and errors look different from the classic builder. Our test image installs curl and copies a small script, so start with a project directory for the two files:

mkdir -p ~/app && cd ~/app && vim Dockerfile

Add the build steps:

FROM alpine:3.22
RUN apk add --no-cache curl
COPY hello.sh /usr/local/bin/hello
CMD ["hello"]

The COPY line needs hello.sh next to the Dockerfile. Create it:

vim hello.sh

The script only prints the container’s hostname, which is its short ID:

#!/bin/sh
echo "hello from $(hostname)"

Make it executable before the build, because COPY keeps the file mode and CMD runs it directly:

chmod +x hello.sh

Then build the image and run it from the same directory. The run prints one line, for example hello from 4dd7a60f7386 in our lab:

docker build -t hello:1.0 .
docker run --rm hello:1.0

A second build of the unchanged file marked the RUN and COPY steps CACHED, and its export step finished in 0.1 seconds, because BuildKit reuses a layer while its instruction and inputs stay the same. Put steps that rarely change, such as package installs, above the COPY of your code, so an edit to the code doesn’t reinstall packages. Use these to control the cache and see where the space went:

JobCommand
Rebuild without cache, print only the IDdocker build --no-cache -q -t hello:1.1 .
Pass a build argument and another filedocker build --build-arg FOO=bar -f Dockerfile -t hello:1.2 .
List builders and their BuildKit versiondocker buildx ls
Build cache usagedocker builder du
Clear all unused build cachedocker builder prune -af

When a COPY source is missing, BuildKit points at the line and prints a long cache key error. The part to read is the file name at the end:

ERROR: failed to build: failed to solve: failed to compute cache key: failed to calculate checksum of ref jru4bporaltxbijhbvf3nx78m::vm32w995i2kh0x2lvp4wfcsrq: "/missing.txt": not found

The path is relative to the build context, the . at the end of the command, and files listed in .dockerignore count as missing too.

Run multi-container apps with Docker Compose

Docker’s packages ship Compose only as the docker compose plugin. The old Python docker-compose is gone from Ubuntu 26.04 too, and typing it in a shell printed docker-compose: command not found. Docker still offers a standalone binary for old scripts, but you don’t need it. Our Docker Compose guide for Ubuntu 26.04 goes deeper. For this sheet we used a three-service file with a health check, a named volume and a profile. Create it in a project directory:

vim compose.yaml

Paste the stack. Note that there is no version: line, which current Compose ignores and warns about:

name: shop
services:
  web:
    image: nginx:1.29
    ports:
      - "8080:80"
    depends_on:
      cache:
        condition: service_healthy
  cache:
    image: redis:8
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 10
    volumes:
      - cachedata:/data
  debug:
    image: alpine:3.22
    profiles: [debug]
    command: ["sleep", "infinity"]
volumes:
  cachedata:

Start it with --wait, which returns only when every service is running and every health check passes, so the next line of a deploy script can trust the stack:

docker compose up -d --wait

We captured Compose from a script, so it printed plain progress lines (in a terminal you get the same steps as a live view). These are the last lines, after the network, volume and containers were created. Compose started Redis, waited for it to report healthy, then started nginx, because web depends on cache with condition: service_healthy. A plain depends_on list only orders the start, so nginx wouldn’t wait for Redis to answer. The debug service stayed down because you didn’t enable its profile:

 Container shop-cache-1 Started
 Container shop-cache-1 Waiting
 Container shop-cache-1 Healthy
 Container shop-web-1 Starting
 Container shop-web-1 Started
 Container shop-cache-1 Waiting
 Container shop-web-1 Waiting
 Container shop-web-1 Healthy
 Container shop-cache-1 Healthy 

These are the Compose commands for a running stack. Run them from the directory with compose.yaml, or add -f and -p:

JobCommand
Service statusdocker compose ps
Logs for one servicedocker compose logs --tail 2 cache
Command in a running servicedocker compose exec cache redis-cli ping
Same, without a TTY (scripts, CI)docker compose exec -T cache redis-cli dbsize
One-off containerdocker compose run --rm cache redis-cli -h cache ping
Start profiled services toodocker compose --profile debug up -d
Images per servicedocker compose images
Restart one servicedocker compose restart web
Pull newer imagesdocker compose pull -q
Every stack on the hostdocker compose ls
Stop without removingdocker compose stop
Remove containers and networksdocker compose down
Also remove volumesdocker compose down -v

Scaling a service with a fixed host port doesn’t work. docker compose up -d --scale web=2 failed on the second replica with Bind for 0.0.0.0:8080 failed: port is already allocated, because a fixed host port can only belong to one container, so scale services that have no ports: or use a port range. And a plain docker compose down left the profiled debug container running, which kept the network alive:

 Container shop-cache-1 Removed
 Network shop_default Removing
 Network shop_default Resource is still in use 

Pass the same profile to down that you used for up. docker compose --profile debug down -v removed the debug container, the network and the shop_cachedata volume in one go. Without -v, named volumes survive down on purpose.

Validate a Compose file before you deploy

docker compose config parses the file, merges overrides and prints the result, so run it in CI before up. Three mistakes produced three different messages in our lab. A leftover version: key only warns. In a terminal the warning looks like this:

WARN[0000] /home/josphat/vtest/compose.yaml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion

In CI logs and other output without a terminal, the same warning comes out as time="2026-10-23T08:30:37Z" level=warning msg=..., so grep for the message text rather than the prefix. A misspelled key under a service fails outright, and the message does not say “unknown key” at all, which is why people end up searching for it:

validating /home/josphat/stack/compose.yaml: services.web.imgae false schema

A misspelled top-level key, here network: instead of networks:, gets the clearer wording. Both errors exit with code 1:

validating /home/josphat/stack/compose.yaml:  additional properties 'network' not allowed

Two more checks are handy. docker compose config --services lists only the services that are active without a profile, and the global -p flag in docker compose -p other config shows what the project would be called with another name.

Sync code into containers with compose watch

For local development, docker compose watch copies changed files into the running container or rebuilds it, based on a develop.watch block. Watch only works for services with a build: section, so our test site built nginx with an html folder baked in. Create the project and its Dockerfile:

mkdir -p ~/watchapp/html && cd ~/watchapp && echo v1 > html/index.html && vim Dockerfile

The image copies the folder in at build time, and watch keeps it in sync afterwards:

FROM nginx:1.29
COPY html /usr/share/nginx/html

Then create compose.yaml in the same directory with vim compose.yaml and a sync rule for the folder:

services:
  site:
    build: .
    ports:
      - "8090:80"
    develop:
      watch:
        - action: sync
          path: ./html
          target: /usr/share/nginx/html

Start the stack with docker compose up -d --build --wait, which served v1, then run docker compose watch in a second terminal. We changed the file with echo v2 > html/index.html and curled localhost:8090. It served the new text within six seconds, and the watcher logged the sync after its BuildKit output (Compose v5 hands builds to Bake, hence the load local bake definitions step):

 Image watchapp-site Built
 Container watchapp-site-1 Running
Watch enabled
Syncing service "site" after 1 changes were detected

Use sync for files the app reads at runtime, rebuild for dependency files such as package.json, and sync+restart for config the process reads only at start.

Keep published ports behind your firewall

Published Docker ports ignore your host firewall rules on both distros. Docker rewrites the destination in the NAT table and forwards the packet to the container, so the INPUT rules that firewalld and ufw manage never see it. We tested it from the other lab VM. On Rocky Linux the public zone allowed only cockpit dhcpv6-client ssh, yet nginx published on 8080 answered while a Python web server on 8085 was blocked:

curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://10.0.1.51:8080
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://10.0.1.51:8085 || echo blocked

The container answered 200 and the host service timed out, so the zone protected the host and did nothing for Docker. On a cloud VM whose security group allows the port, the internet sees the same thing, which is how databases published with -p 5432:5432 end up exposed:

200
000
blocked

Ubuntu with ufw default deny incoming and only OpenSSH allowed gave exactly the same result. On Rocky, where we had installed and started firewalld (many cloud images don’t run it), Docker also puts docker0 into its own firewalld zone with target ACCEPT and creates a docker-forwarding policy, which sudo firewall-cmd --get-active-zones shows next to your public zone. Use one of these instead of a host firewall rule:

  • Publish on localhost and put a reverse proxy in front: -p 127.0.0.1:8080:80 (remote curl timed out, local curl got 200)
  • Block the port in the DOCKER-USER chain, which Docker evaluates before its own rules
  • Leave ports unpublished and let a proxy container reach the service over a Docker network

This is the DOCKER-USER rule we tested on Ubuntu. It drops outside connections to whatever the container listens on behind host port 8080, and the remote curl went from 200 to a timeout:

sudo iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 127.0.0.0/8 -j DROP

A rule added with iptables is gone after a reboot, and saving the whole ruleset would also save Docker’s own chains, which Docker rebuilds on every start. That’s why we prefer the localhost binding: it lives in the container definition and needs nothing else.

Use bind mounts with SELinux on Rocky Linux

Docker CE does not label containers for SELinux unless you ask it to, which is why bind mounts “just work” on a stock Rocky host even in enforcing mode. With getenforce showing Enforcing, nginx served a file from /srv/site (label var_t) without any :Z while Docker ran with its defaults. If you want SELinux to confine containers, and on a shared server you should, turn it on in daemon.json:

sudo vim /etc/docker/daemon.json

Set the option, then restart Docker. If the file already has settings, add the key inside the existing braces. On a server that already runs containers, add :z or :Z to their bind mounts first, because the restart stops running containers (unless live-restore is on) and anything recreated afterwards with an unlabelled bind mount hits the same error shown below:

{
  "selinux-enabled": true
}

After sudo systemctl restart docker, docker info listed name=selinux under security options. Now create a test page and mount it without a label option to see what breaks:

sudo mkdir -p /srv/site && echo '<h1>selinux test</h1>' | sudo tee /srv/site/index.html
docker run -d --name s2 -p 8083:80 -v /srv/site:/usr/share/nginx/html:ro nginx:1.29
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8083

The request failed with 403, and nginx logged "/usr/share/nginx/html/index.html" is forbidden (13: Permission denied). The confusing part is that sudo ausearch -m avc -ts recent printed <no matches>, because the policy silences (dontaudit) this denial. To see it, turn off dontaudit rules, repeat the request so the denial happens again, read the log, then turn them back on:

sudo semodule -DB
curl -s -o /dev/null http://localhost:8083
sudo ausearch -m avc -ts recent | grep -o 'avc:.*denied.*tclass=[a-z_]*'
sudo semodule -B

With dontaudit off, the denial appeared, naming the container’s container_t domain and the file’s var_t label. Always run the last semodule -B, because without it the audit log fills with noise:

avc:  denied  { getattr } for  pid=85134 comm="nginx" path="/usr/share/nginx/html/index.html" dev="sda4" ino=8574913 scontext=system_u:system_r:container_t:s0:c73,c445 tcontext=unconfined_u:object_r:var_t:s0 tclass=file

Fix it by asking Docker to relabel the directory when it mounts it. :Z gives the files a private label for this one container, and :z a shared one that several containers can use:

docker rm -f s2
docker run -d --name s3 -p 8083:80 -v /srv/site:/usr/share/nginx/html:ro,Z nginx:1.29

nginx served the page again, and ls -Z showed the new label with the container’s MCS categories, system_u:object_r:container_file_t:s0:c196,c236. The categories change with every container, so a second container can’t read files labelled for the first. With :z the label became plain container_file_t:s0. Three cautions apply here:

  • Never relabel system or home directories such as /home or /usr this way
  • --mount does not accept the option: invalid field 'Z' must be a key=value pair
  • restorecon leaves the new label in place (“not reset as customized by admin”)

Keep the setting on if you want SELinux to confine your containers. To undo the demo instead, run docker rm -f s3, delete /srv/site, and remove the selinux-enabled line before you restart Docker, which brought docker info back to seccomp and cgroupns only.

Fix common Docker errors

These are the errors we triggered on the two lab hosts, with the exact Engine 29 wording. Several changed in recent releases, so older answers you find for them quote text your host no longer prints.

Error: “permission denied while trying to connect to the docker API at unix:///var/run/docker.sock”

Your user is not in the docker group, or you added it and have not logged in again. The CLI that ships with Engine 29 says “docker API” here, where older releases said “Docker daemon socket”. Run sudo usermod -aG docker $USER, then open a new session or run newgrp docker, and check with id -nG.

Error: “Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?”

Nothing is listening on the socket. We got this after sudo systemctl stop docker.socket docker.service, and the same wording with a TCP address when DOCKER_HOST pointed at a port with no daemon. Start Docker with sudo systemctl start docker, and if it will not start, read journalctl -u docker, because the real reason is there. Also check echo $DOCKER_HOST and docker context ls in case the CLI is pointed somewhere else.

Error: “Extension addrtype revision 0 not supported, missing kernel module?”

This one stopped Docker from starting on our Rocky Linux 10 cloud VM. The journal showed failed to start daemon: Error initializing network controller ending in this iptables message. The cloud image ships only kernel-modules-core. When kernel-modules-extra arrived alongside firewalld and iptables-nft, dnf installed it for the newer kernel a dnf upgrade had staged, not for the one running. modprobe xt_addrtype confirmed the module was missing for the booted kernel. Either reboot into the new kernel, or install the extras for the running one and restart Docker. The second fix only works while that kernel build is still in the BaseOS repository, so on an older install the reboot is the way out:

sudo dnf install "kernel-modules-extra-$(uname -r)"
sudo systemctl reset-failed docker
sudo systemctl restart docker

Docker came up, and lsmod showed xt_addrtype and nft_compat loaded.

Error: “Bind for 0.0.0.0:8080 failed: port is already allocated”

Another container already publishes that host port. The full message starts with failed to set up container networking: driver failed programming external connectivity on endpoint web2. Find the owner with docker ps --filter publish=8080 and pick another host port. The failed container is left behind in the Created state, so remove it with docker rm web2 before you retry with the same name.

Error: “failed to bind host port 0.0.0.0:8081/tcp: address already in use”

A process outside Docker holds the port, in our case a Python web server. sudo ss -ltnp 'sport = :8081' names it, and our Linux commands cheat sheet has more ways to find what holds a port. Stop that process or publish on a different host port.

Error: “conflict: unable to delete nginx:1.29 (must be forced)”

A container, even a stopped one, still uses the image. The containerd store words it as container 8b47ac344ad7 is using its referenced image 1881968aff6f, which is different from the legacy store’s “unable to remove repository reference” text. Remove the container first with docker rm -f, then the image.

Error: “exec format error”

The image was built for another CPU architecture. For a multi-platform image whose host variant is also stored, docker image inspect -f '{{.Architecture}}' reports the host’s platform even after you pulled the arm64 variant, which is what we saw with alpine. Check with docker image ls --tree instead, which shows which platform actually has data. Then pull or build the variant that matches the host, for example with --platform linux/amd64 on an x86 server.

Error: “executable file not found in $PATH”

The command you passed after the image name doesn’t exist in the image. The full message reads docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "nonexistent-cmd": executable file not found in $PATH and docker run exits with 127. Minimal images such as Alpine have sh but not bash, so try sh first.

Error: “pull access denied for cfglab/does-not-exist-9x, repository does not exist or may require ‘docker login'”

Docker Hub says the same thing for a typo and for a private repository, so check the spelling and the namespace first, then docker login if the repository really is private.

Error: “cannot attach stdin to a TTY-enabled container because stdin is not a terminal”

You used -it in a script, cron job or CI step. Drop -t (keep -i only if you pipe input in), or use docker compose exec -T for Compose services.

Reclaim disk space without losing data

The prune commands differ in what they consider unused, and only one of them touches named volumes. We set up a stopped container, an anonymous volume, a named volume in use, an idle named volume, rebuilt images and build cache, then ran each prune in turn on Ubuntu (Rocky matched apart from a 0 B container prune). Check the totals first:

docker system df

Half the image space was reclaimable before we started:

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          7         2         494.5MB   247.8MB (50%)
Containers      2         1         16.38kB   12.29kB (75%)
Local Volumes   3         3         6B        0B (0%)
Build Cache     10        0         28.3MB    28.77kB

This is what each command removed, in the order we ran them:

CommandWhat it removed in our testReclaimed
docker container prune -fThe stopped container only12.29kB
docker image prune -fNothing: rebuilding a tag left no dangling images on the containerd store0B
docker volume prune -fThe anonymous volume, not the named ones0B
docker system prune -a --volumes -fEvery image not used by a container, all build cache. Named volumes appdata and keepme survived279MB
docker volume prune -a -fBoth named volumes, once no container used them6B

So --volumes on system prune only covers anonymous volumes, and the command that can delete your database is docker volume prune -a. Before any prune on a server, check what you’d lose:

  • docker volume ls for named volumes you still need
  • docker ps -a for stopped containers you plan to start again
  • docker system df for how much is reclaimable

Images you remove can be pulled again, but volumes and stopped containers can’t. On a build server, docker system prune -a -f --filter 'until=24h' leaves anything that arrived in the last day alone on the containerd store, which compares the time the image was pulled or tagged. An upgraded host on overlay2 compares the image’s build date instead, so there the same filter deletes an old image you pulled this morning. On our Ubuntu host it reclaimed 0B, keeping a five-month-old nginx image we had pulled that morning and 250MB of fresh build cache, while the same command without the filter reclaimed 648.9MB.

For the theory behind images, layers, networking and Compose, Nigel Poulton’s Docker Deep Dive (paid link) (2025 edition, Kindle) is the book we hand to people who are new to Docker. If you are weighing Docker against Podman or plain containerd for servers, our comparison of Docker, CRI-O and containerd covers the runtime side, and the systemctl cheat sheet helps when the Docker service itself misbehaves.

Keep reading

Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) AI Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) AI OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) Best UI Applications for Managing Docker Containers Containers Best UI Applications for Managing Docker Containers Kubectl Cheat Sheet for Kubernetes Admins and CKA Exam Prep Kubernetes Kubectl Cheat Sheet for Kubernetes Admins and CKA Exam Prep wslc Cheat Sheet: WSL Containers Commands vs Docker (PDF) Cheat Sheets wslc Cheat Sheet: WSL Containers Commands vs Docker (PDF) How To Run Flatcar Container Linux on OpenStack Cloud How To Run Flatcar Container Linux on OpenStack

Leave a Comment

Press ESC to close