
Linux command: docker
Someone hands you a project and says "just run it in Docker". Ten minutes later you have a container that starts and immediately exits, a port that answers on your laptop but not from the network, and a folder full of files owned by root that you cannot delete. None of that is Docker being difficult. It is Docker being exactly what it is: a very thin wrapper around a handful of Linux kernel features, wearing a friendly command line. Once you see what is underneath, the surprises stop.
1. The Basics
Docker packages an application together with everything it needs to run, and then starts it as an ordinary process on your machine that thinks it has the machine to itself. That is the whole idea. Everything else is detail.
1.1 The Simplest Possible Use
One command proves the whole installation works:
$ docker run hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.
That message is worth reading properly, because it lists the four things that happen every single time you type docker run, and three of them are not obvious.
1.2 Image, Container, Registry
Three words carry most of the meaning, and mixing them up is the source of half of all Docker confusion.
| Word | What it is | Everyday comparison |
|---|---|---|
| Image | A read-only, layered filesystem plus a bit of configuration (which command to run, which ports, which environment variables). It never changes. | The installer, or a class in programming |
| Container | A running (or stopped) instance of an image, with a thin writable layer on top of it. | The installed, running program, or an object |
| Registry | A server that stores images so you can pull and push them. Docker Hub is the default one. | The package repository |
You can make many containers from one image, and they do not affect each other. Here are two containers from the same alpine image, where one edits a file:
$ docker run -d --name c1 alpine:3.20 sleep 300
$ docker run -d --name c2 alpine:3.20 sleep 300
$ docker exec c1 sh -c 'echo "changed by c1" > /etc/motd'
$ docker exec c1 cat /etc/motd
changed by c1
$ docker exec c2 cat /etc/motd
Welcome to Alpine! # untouched
$ docker run --rm alpine:3.20 cat /etc/motd
Welcome to Alpine! # the image itself never changed
Docker calls this copy on write. The image layers stay shared and read-only, and each container gets its own private layer that records only what it changed. You can see exactly what a container changed:
$ docker diff c1
C /etc
C /etc/motd # C changed, A added, D deleted
The kernel feature doing this is a union filesystem. Docker's default storage driver is called overlay2, and it builds each container's filesystem out of OverlayFS directories that you can look at directly:
$ docker inspect -f '{{json .GraphDriver}}' c1
Name: overlay2
LowerDir = /var/lib/docker/overlay2/df4d83b6.../diff:... # the image layers
UpperDir = /var/lib/docker/overlay2/df4d83b6.../diff # this container
MergedDir = /var/lib/docker/overlay2/df4d83b6.../merged # what it sees
WorkDir = /var/lib/docker/overlay2/df4d83b6.../work
The read-only image layers are the lower directories, the container's private layer is the upper one, and the container sees the merged view of the two. Starting a container therefore does not copy a root filesystem, which is why it takes about a fifth of a second rather than the minute a virtual machine needs:
$ time docker run --rm alpine:latest true
0.17 s
There is a cost hiding in that design, and it is worth knowing before you meet it. When a container writes to a file that lives in a lower layer, OverlayFS must first copy the whole file up into the writable layer. Not the changed part: the whole file.
# an image carrying one 200 MB file
$ printf 'FROM alpine:latest\nRUN dd if=/dev/zero of=/data.bin bs=1M count=200\n' > Dockerfile
$ docker build -t cowdemo:1 .
$ docker run -d --name cow1 cowdemo:1 sleep 300
$ docker ps -s --format '{{.Names}} {{.Size}}'
cow1 0B (virtual 218MB) # the writable layer is empty
$ docker exec cow1 sh -c 'echo x >> /data.bin' # append two bytes
$ docker ps -s --format '{{.Names}} {{.Size}}'
cow1 210MB (virtual 427MB) # the whole file was copied up
Two bytes cost 210 MB. For source code and configuration this never matters. For a database file, a log file or a large upload it matters enormously, and it is one of the concrete reasons that data belongs in a volume (section 5.1) rather than in the container's own filesystem. A volume is a normal directory on the host, mounted straight in, with no layering and no copy-up.
1.3 A Container Is a Process, Not a Machine
This is the single most useful thing to understand, and almost every beginner gets it wrong. A container is not a small virtual machine. There is no second kernel, no boot process, no BIOS. A container is a normal Linux process that the kernel has been asked to lie to.
Start a container and look inside it:
$ docker run -d --name demo alpine:3.20 sleep 600
$ docker exec demo ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 600
The container is convinced it is process number 1 on an otherwise empty machine. Now look at the same process from the host:
$ docker top demo
UID PID PPID C STIME TTY TIME CMD
root 438607 438586 0 19:42 ? 00:00:00 sleep 600
$ ps -ef | grep "sleep 600"
root 438607 438586 0 19:42 ? 00:00:00 sleep 600
It is just a process, with a perfectly ordinary process ID, visible in ps like anything else. And it runs on your kernel, not on one of its own:
$ uname -r
6.11.0-29-generic
$ docker exec demo uname -r
6.11.0-29-generic # the same kernel, because there is only one
Two kernel features do all the work. Namespaces give the process a private view of one part of the system: its own process list, its own network interfaces, its own hostname, its own mount table. Control groups (cgroups) put a ceiling on how much CPU, memory and I/O it may use. You can inspect the namespaces a container was put into:
$ docker run --rm alpine:3.20 ls -l /proc/self/ns/
pid -> pid:[4026533329] # private
net -> net:[4026533332] # private
mnt -> mnt:[4026532489] # private
uts -> uts:[4026532490] # private (this is the hostname)
user -> user:[4026531837] # NOT private: same as the host
$ ls -l /proc/self/ns/user
user -> user:[4026531837] # identical number
Notice the last one. By default Docker does not give a container its own user namespace, which means root inside the container is the same root the host knows. Section 7.4 shows what that costs you, and how userns-remap closes it.
The right mental model: a container is one process, fenced off by namespaces and bounded by a cgroup. Docker did not invent that. Docker made it a one-line command.
1.4 The Client and the Daemon
The docker you type is only a client. It does nothing itself. It sends HTTP requests over a Unix socket to a background service, dockerd, which owns every image, container, volume and network on the machine.
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Aug 21 09:48 /var/run/docker.sock
You can talk to it yourself, without the Docker CLI at all:
$ curl -s --unix-socket /var/run/docker.sock http://localhost/version
{"Platform":{"Name":"Docker Engine - Community"},"Components":[{"Name":"Engine",
"Version":"28.1.1","Details":{"ApiVersion":"1.49","Arch":"amd64", ...
Two consequences follow from that single socket, and both matter. First, "Docker is slow to start" usually means the daemon is doing something, not the CLI. Second, and much more important: that socket is owned by root and the group docker. Anyone in that group can ask the daemon to do anything root can do. Section 7.4 demonstrates it.
The daemon is not the end of the chain either. Modern Docker is four programs, and you can watch the handover by following a container's parent processes:
$ docker inspect -f '{{.State.Pid}}' demo
455660
$ ps -o pid,ppid,comm -p 455660
PID PPID COMMAND
455660 455639 sleep
$ ps -o pid,comm,args -p 455639
455639 containerd-shim /usr/bin/containerd-shim-runc-v2 -namespace moby -id e18f2f...
Each piece has one job:
| Component | Job |
|---|---|
docker |
The CLI. Turns your command into an HTTP request and prints the answer. |
dockerd |
Images, volumes, networks, builds, the API. The Docker-specific part. |
containerd |
The container lifecycle and image storage. A general runtime, not Docker-only. |
containerd-shim-runc-v2 |
One per container. Holds its stdout and its exit code. |
runc |
Creates the namespaces and the cgroup, then execs your program and exits. |
The detail worth noticing is what is not in that process tree. There is no runc and no dockerd above your container, because runc finishes its work and leaves, and the shim is a child of init rather than of the daemon:
$ ps -o pid,ppid,comm -C dockerd -C containerd
PID PPID COMMAND
3249 1 containerd # both are ordinary services,
3680 1 dockerd # started by systemd, side by side
That layout is what makes a container's life independent of the daemon's: since the shim owns the process and answers to init, dockerd can go away and come back without the container needing to notice. It is also why Kubernetes could drop dockershim and talk to containerd directly (section 3) without changing a single image. Docker is the top two boxes of a five-box stack, and the bottom three are shared with everyone else.
Whether containers actually survive a daemon restart is a separate setting, and the default catches people out:
$ docker info | grep "Live Restore"
Live Restore Enabled: false # containers stop when dockerd stops
Set "live-restore": true in /etc/docker/daemon.json on a server, and a daemon upgrade stops being an outage.
2. Where the Name Comes From
A docker is a dock worker: the person on the quayside who loads and unloads ships. The name was chosen for the metaphor that the whole industry now runs on.
Before the 1950s, cargo was loaded piece by piece, and every ship, train and truck needed its own handling. The intermodal shipping container fixed that by standardising the box rather than the contents. Nobody at the port needs to know whether a container holds bananas or bicycles. The crane, the ship and the truck all handle the same standard box in the same standard way.
Software had the same problem. A Python application, a PHP application and a Go binary each needed their own deployment procedure, their own dependencies, their own "works on my machine" argument. A container image standardises the box: whatever is inside, the runtime starts it the same way.
The whale in the logo is called Moby Dock, a pun on Herman Melville's Moby Dick, and the community picked the name in a 2013 vote. In 2017 Docker moved the open source engine into a project named Moby, keeping "Docker" for the products the company sells. That is why the source you find on GitHub lives under moby/moby, and why the build engine is moby/buildkit.
The rest of the vocabulary is worth learning in one go, because every error message uses it:
image a read-only stack of filesystem layers plus configuration
layer one filesystem change set; images are made of these, stacked
container a process started from an image, with a writable layer on top
registry a server that stores images (Docker Hub is the default)
repository one named collection of images in a registry, e.g. "nginx"
tag a label on one image inside a repository, e.g. "1.27-alpine"
digest the sha256 hash of an image; unlike a tag, it never moves
volume Docker-managed storage that outlives the container
bind mount a host directory pushed into the container at a chosen path
Dockerfile the recipe used to build an image
context the directory sent to the builder when you run docker build
ENTRYPOINT the program a container runs
CMD the default arguments (or the default program, if no ENTRYPOINT)
Two of those are worth flagging now. A tag is not a version, it is a moving label, and section 7 shows how that bites. A volume is not a bind mount, and section 5.1 shows why they behave differently in a way that catches everyone once.
Back to top3. A Short History
Docker did not invent containers. Unix had chroot in 1979, FreeBSD had jails in 2000, Solaris had Zones in 2005, and Linux had LXC from 2008 built on the namespaces and cgroups that had been landing in the kernel for years. All the pieces existed. What did not exist was a way for a normal developer to use them without becoming a kernel expert.
Docker started as an internal tool at dotCloud, a platform-as-a-service company. In March 2013 it was shown publicly for the first time at PyCon in Santa Clara and released as open source. It was not a new capability. It was a package format, a registry and a command line wrapped around capabilities Linux already had, and that turned out to be the missing piece.
| Era | Milestone |
|---|---|
| 1979 | chroot appears in Unix: change a process's idea of /. The first ancestor. |
| 2000 to 2008 | FreeBSD jails, Solaris Zones, then Linux namespaces and cgroups, and LXC on top of them. |
| March 2013 | Docker is shown at PyCon and open sourced. dotCloud later renames itself Docker. |
| March 2014 | Docker 0.9 drops LXC as the default and ships its own libcontainer, written in Go. |
| June 2014 | Docker 1.0. Container images start appearing in production everywhere. |
| June 2015 | The Open Container Initiative forms at the Linux Foundation. Docker donates the runtime code that becomes runc, and the image format becomes a public standard. |
| 2016 to 2017 | containerd is split out and donated to the CNCF. The open source engine is renamed the Moby project. |
| 2019 | Mirantis buys Docker Enterprise. Docker Inc. keeps Docker Desktop and Docker Hub. |
| May 2022 | Kubernetes 1.24 removes dockershim. Kubernetes now talks to containerd directly. Your images still work; they were OCI images all along. |
| February 2023 | Docker Engine 23.0 makes BuildKit the default builder on Linux, so docker build quietly became a much better program. |
| July 2023 | Compose V1 (the Python docker-compose) reaches end of life. docker compose, a Go plugin, replaces it. |
Two entries in that table explain confusion you will still meet in 2026. The Kubernetes one produced a wave of "Kubernetes is dropping Docker" headlines that frightened people unnecessarily: what was dropped was a translation shim, not the image format, and every image built with Docker still runs. The Compose one is why half the tutorials on the internet write docker-compose up with a hyphen and half write docker compose up with a space. The space is the current one.
Back to topDocker's real achievement was not technical, it was standardisation. Because of the OCI, an image you build today runs under Docker, Podman, containerd, CRI-O and Kubernetes without changes. The box is standard, whatever you put in it.
4. Simple Use Cases
4.1 Run Something and Throw It Away
The most under-used Docker feature is running a program you have not installed. Two flags make it pleasant:
| Flag | Short for | What it does |
|---|---|---|
--rm |
remove | Delete the container as soon as it exits |
-i |
interactive | Keep standard input open, so you can type into it |
-t |
tty | Give it a terminal, so prompts and colours work |
$ docker run --rm -it alpine:3.20 sh
/ # cat /etc/alpine-release
3.20.10
/ # exit # container is gone, nothing left behind
You almost always want -it together, which is why people type -it without thinking about it. Use them when you expect to interact; leave them off in scripts and cron jobs, where there is no terminal to attach to.
This is a genuinely useful habit. Need to test a DNS query with a tool you do not have installed? Need PHP 7.4 for ten seconds to check something? Run it, use it, and it disappears:
$ docker run --rm alpine:3.20 nslookup petermartin.nl
$ docker run --rm -v "$PWD:/app" -w /app php:7.4-cli php -l index.php
4.2 Run Something in the Background
For anything that should keep running, use -d (short for detached) and give it a name:
$ docker run -d --name web -p 8080:80 nginx:latest
c4f1d0a9b0e2f3a7c1d8e9b2a4f6c8d0e1a3b5c7d9e1f3a5b7c9d1e3f5a7b9c1
That long string is the container ID. You will almost never need it, because you gave the container a name. Always name your containers. Docker will invent one otherwise, and while elegant_hopper is charming the first time, it is useless in a script.
4.3 See What Is Running
$ docker ps # running containers only
CONTAINER ID IMAGE STATUS PORTS NAMES
c4f1d0a9b0e2 nginx:latest Up 2 minutes 0.0.0.0:8080->80/tcp web
$ docker ps -a # -a for all: includes stopped ones
$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
Run docker ps -a on a machine that has been used for a while and the result is often a shock:
$ docker ps -a --format '{{.Status}}' | grep -c Exited
121
Those 121 stopped containers are not running, but each one still owns its writable layer on disk. Section 6.9 deals with that.
4.4 Reading the Logs
docker logs shows what the container's main process wrote to standard output and standard error:
$ docker logs web # everything so far
$ docker logs -f web # -f for follow, like tail -f
$ docker logs --tail 50 web # the last 50 lines
$ docker logs --since 10m web # only the last ten minutes
$ docker logs -t web # -t adds timestamps
The important limitation is easy to miss. Docker captures the standard output and standard error of PID 1 only. Anything the application writes to a log file inside the container is invisible to docker logs:
$ docker run -d --name l1 alpine:3.20 sh -c \
'echo to-stdout; echo to-stderr >&2; echo to-file > /tmp/f.log; sleep 60'
$ docker logs l1
to-stdout
to-stderr # both streams appear
$ docker exec l1 cat /tmp/f.log
to-file # this never reaches docker logs
This is why well-behaved container images log to standard output instead of to a file. If you containerise an application that insists on writing /var/log/app.log, the usual trick is to symlink that file to /dev/stdout. The official nginx image does exactly that.
4.5 Get a Shell in a Running Container
docker exec starts an extra process inside a container that is already running:
$ docker exec -it web bash # a shell, for poking around
$ docker exec web nginx -t # one command, no terminal needed
$ docker exec -u root -it web sh # as root, if the image runs as someone else
Two things go wrong here regularly. If the image is based on Alpine there is no bash, only sh. And exec only works on a running container: if the container keeps exiting, there is nothing to exec into, and you need docker logs instead. To inspect a broken image without starting its normal command, override it:
$ docker run --rm -it --entrypoint sh myimage:latest
4.6 Stop, Start, Remove
$ docker stop web # polite: SIGTERM, then SIGKILL after 10s
$ docker start web # start it again, writable layer intact
$ docker restart web
$ docker kill web # immediate SIGKILL, no grace period
$ docker rm web # delete the container (must be stopped)
$ docker rm -f web # stop and delete in one step
Keep stop and rm apart in your head. stop pauses the story: the container's writable layer is still there and docker start resumes it. rm ends it: the writable layer is deleted and everything the container wrote outside a volume is gone forever.
The ten seconds in that first line are not arbitrary, and section 7.2 explains why almost every container you stop takes the full ten.
4.7 Publishing a Port
A container's network is private. A web server listening on port 80 inside a container is not reachable from your browser until you publish the port with -p:
$ docker run -d --name web -p 8080:80 nginx:latest
^^^^ ^^
host container
The order is host first, container second, and getting it backwards is a rite of passage. Check what actually happened:
$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES PORTS
web 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
200
Look closely at 0.0.0.0. That means every network interface, so anyone who can reach your machine can reach that container. On a laptop on a shared network, or on a server with a public IP, that is usually not what you meant. You can bind to one address instead:
$ docker run -d --name web -p 127.0.0.1:8080:80 nginx:latest
$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES PORTS
web 127.0.0.1:8080->80/tcp # localhost only
$ ss -tln | grep 8080
LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:*
Make 127.0.0.1: your default for anything that a reverse proxy will sit in front of. Section 7.7 explains why this matters more than you would expect.
5. Moderate Use Cases
5.1 Keeping Data: Volumes and Bind Mounts
Everything a container writes to its own filesystem disappears when you docker rm it. For a database, that is a catastrophe waiting for a Tuesday. Docker offers two ways to keep data, and they are not interchangeable.
| Named volume | Bind mount | |
|---|---|---|
| Syntax | -v mydata:/var/lib/mysql |
-v /home/peter/site:/var/www |
| Lives | In /var/lib/docker/volumes/, managed by Docker |
Wherever you point it, managed by you |
| Portable | Yes, same on any host | No, the host path must exist |
| Best for | Databases, uploads, anything the app owns | Source code during development, config files |
There is one behavioural difference that catches everyone exactly once. A named volume that is empty gets pre-filled from the image. A bind mount hides whatever the image had at that path:
$ docker volume create demo-vol
$ docker run --rm -v demo-vol:/etc alpine:3.20 sh -c 'ls /etc | wc -l'
36 # the image's /etc was copied in
$ mkdir /tmp/empty
$ docker run --rm -v /tmp/empty:/etc alpine:3.20 sh -c 'ls /etc | wc -l'
3 # the image's /etc is hidden
That is the mechanism behind "I mounted my code and now the container cannot find its dependencies". You bind-mounted your project over a directory that the image had already populated during the build, and the build's work is still there, just covered up.
The second surprise is ownership. A container runs as root by default, so the files it creates on a bind mount belong to root on your host:
$ docker run --rm -v /tmp/uid:/data alpine:3.20 touch /data/from-container
$ ls -ln /tmp/uid/
-rw-r--r-- 1 0 0 0 Aug 23 19:43 from-container # owned by UID 0
$ id -u
1000 # but you are UID 1000
The fix is to tell Docker which user to be:
$ docker run --rm --user "$(id -u):$(id -g)" -v /tmp/uid:/data \
alpine:3.20 touch /data/from-me
$ ls -ln /tmp/uid/
-rw-r--r-- 1 1000 1000 0 Aug 23 19:48 from-me # yours
Useful volume commands:
$ docker volume ls
$ docker volume inspect mydata # where it lives on disk
$ docker volume rm mydata
$ docker run --rm -v mydata:/data -v "$PWD:/backup" alpine:3.20 \
tar czf /backup/mydata.tar.gz -C /data . # back a volume up
5.2 Networks and Name Resolution
Containers on the same Docker network can reach each other by name, which is how a web container finds its database without anyone knowing an IP address. But this only works on a user-defined network, not on the default bridge that plain docker run uses:
$ docker run -d --name n1 alpine:3.20 sleep 300
$ docker run --rm alpine:3.20 ping -c1 n1
ping: bad address 'n1' # default bridge: no DNS
$ docker network create mynet
$ docker run -d --name n2 --network mynet alpine:3.20 sleep 300
$ docker run --rm --network mynet alpine:3.20 ping -c1 n2
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.054 ms # works
The resolution is done by a small DNS server that Docker runs inside every container on a user-defined network:
$ docker run --rm --network mynet alpine:3.20 cat /etc/resolv.conf
nameserver 127.0.0.11 # Docker's embedded resolver
search .
options edns0 trust-ad ndots:0
This is one of the strongest reasons to use Compose (section 5.7): it creates a network for you, so service names just work.
Two more things to know about container networking:
localhostinside a container is the container. An application configured to reach a database at127.0.0.1:3306will fail inside a container, because the database is a different container. Use the service name.- To reach the host from a container on Linux, add
--add-host=host.docker.internal:host-gateway, then connect tohost.docker.internal. On Docker Desktop that name exists already.
5.3 Environment Variables
Configuration goes in through the environment, which is how the same image can serve development and production:
$ docker run -d -e MYSQL_ROOT_PASSWORD=secret mariadb:10.6
$ docker run -d --env-file ./.env myapp:1.0 # read many at once
$ docker exec web printenv | sort # see what a container got
Two warnings. Anything you pass with -e is visible in docker inspect to anyone who can talk to the daemon, so it is not a secret store. And anything you bake in with ENV in a Dockerfile is in the image permanently, for everyone who pulls it. Section 7.5 shows how visible that really is.
5.4 Writing a Dockerfile
A Dockerfile is a list of instructions. Each one produces a layer, and the result is an image.
FROM php:8.3-fpm-alpine # the base image to start from
WORKDIR /var/www/html # default directory for later steps
RUN apk add --no-cache icu-dev \
&& docker-php-ext-install intl pdo_mysql
COPY composer.json composer.lock ./ # dependencies first (see 6.1)
RUN composer install --no-dev --optimize-autoloader
COPY . . # then the application code
USER www-data # stop being root (see 6.4)
EXPOSE 9000 # documentation, not a firewall rule
CMD ["php-fpm"]
The instructions you will use nearly every time:
| Instruction | What it does |
|---|---|
FROM |
The base image. Every Dockerfile starts here. |
RUN |
Run a command while building, and keep the result as a layer. |
COPY |
Copy files from the build context into the image. |
ADD |
Like COPY, but also unpacks archives and fetches URLs. Prefer COPY. |
WORKDIR |
Set the working directory for the instructions that follow. |
ENV |
Set an environment variable, permanently, in the image. |
ARG |
A variable available only during the build. |
USER |
Switch to another user for the rest of the build and for runtime. |
EXPOSE |
Declare a port. Documentation only; it publishes nothing. |
ENTRYPOINT |
The program the container runs. |
CMD |
Default arguments to ENTRYPOINT, or the default command if there is none. |
EXPOSE deserves a special mention because it disappoints people. It does not open a port. It records, in the image metadata, which port the application listens on, so that humans and tools can read it. Only -p at run time actually publishes anything.
ENTRYPOINT and CMD together are the other common confusion. The rule: ENTRYPOINT is the program, CMD is the default arguments, and anything you type after the image name on the command line replaces CMD.
ENTRYPOINT ["ping"]
CMD ["-c", "3", "localhost"]
$ docker run myping # runs: ping -c 3 localhost
$ docker run myping -c 1 8.8.8.8 # runs: ping -c 1 8.8.8.8
Both accept two syntaxes, and the difference is real. The exec form CMD ["ping", "localhost"] runs the program directly. The shell form CMD ping localhost wraps it in /bin/sh -c, which means shell features work but the shell becomes part of the picture:
# two images whose CMD differs only in its form
$ cat Dockerfile.shell $ cat Dockerfile.exec
FROM alpine:3.20 FROM alpine:3.20
CMD echo "hello $NAME" CMD ["echo","hello $NAME"]
$ docker run --rm -e NAME=peter demo:shell
hello peter # a shell ran it, so $NAME expanded
$ docker run --rm -e NAME=peter demo:exec
hello $NAME # no shell involved, nothing expanded it
Use the exec form by default. Use the shell form only when you genuinely need a pipe, a redirect or a variable, and understand section 7.2 before you do.
5.5 Building and Tagging
$ docker build -t myapp:1.0 . # -t for tag; the . is the build context
$ docker build -t myapp:1.0 -t myapp:latest .
$ docker build -f docker/Dockerfile.prod -t myapp:prod .
$ docker build --no-cache -t myapp:1.0 . # ignore the cache entirely
That final . is not "here", it is the build context: the directory that gets packed up and sent to the build engine. Section 6.2 explains why that matters a great deal.
Tag with real version numbers, not just latest. A tag is a movable label, so myapp:latest means "whatever I last pushed", which is exactly the information you need when a deployment goes wrong and exactly the information a moving tag cannot give you.
5.6 Restart Policies
Containers exit. Applications crash, the daemon restarts, the server reboots. A restart policy tells Docker what to do about it:
$ docker run -d --restart unless-stopped --name web nginx:latest
| Policy | Behaviour |
|---|---|
no |
The default. Never restart. |
on-failure[:n] |
Restart only on a non-zero exit code, at most n times. |
always |
Always restart, including after a daemon restart, even if you stopped it by hand. |
unless-stopped |
Like always, but if you stopped it deliberately it stays stopped. |
unless-stopped is the right default for a service. It survives reboots, and it respects your decision when you stop something on purpose. Docker uses an increasing delay between attempts, so a container that crashes on start does not spin the CPU:
$ docker inspect -f 'restarts={{.RestartCount}} status={{.State.Status}}' r1
restarts=3 status=running
5.7 docker compose
Once you have three containers, typing docker run with fifteen flags each becomes a memory test. Compose puts the whole set in one file:
services:
web:
image: nginx:1.27-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./public_html:/usr/share/nginx/html:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:10.6
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: joomla
volumes:
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
dbdata:
$ docker compose up -d # build if needed, create, start
$ docker compose ps
$ docker compose logs -f web # logs of one service
$ docker compose exec web sh # a shell in one service
$ docker compose down # stop and remove containers and network
$ docker compose down -v # ... and delete the named volumes too
Compose names everything after the project, which is the directory name unless you override it with -p:
$ docker compose up -d
$ docker compose ps
NAME IMAGE SERVICE STATUS
comp-cache-1 alpine:3.20 cache Up 8 seconds
comp-web-1 nginx:latest web Up 8 seconds (healthy)
$ docker network ls | grep comp
6d7ac0548fc6 comp_default bridge local
Note the two payoffs. The containers share a network called comp_default, so web and cache can reach each other by service name with no configuration at all. And docker compose ps reports health, which plain docker ps also does but which almost nobody sets up outside Compose.
The one detail worth memorising: docker compose down removes containers and networks but keeps named volumes. Only down -v deletes them:
$ docker compose down
$ docker volume ls | grep comp
local comp_dbdata # your database survived
$ docker compose down -v
Volume comp_dbdata Removed # your database did not
That default is deliberate and it is the right one. It also means down -v is a command to type slowly.
6. Advanced Use Cases
6.1 Layers and the Build Cache
Every RUN, COPY and ADD in a Dockerfile creates a layer, and docker history shows you the stack:
$ docker history myapp:1
IMAGE CREATED BY SIZE
a07370c4d5e3 CMD ["cat" "/app/app.txt"] 0B
<missing> COPY app.txt /app/app.txt # buildkit 10B
<missing> RUN /bin/sh -c apk add --no-cache curl 5.41MB
<missing> CMD ["/bin/sh"] 0B
<missing> ADD alpine-minirootfs-3.20.10-x86_64.tar.gz / 7.81MB
Docker reuses a layer if the instruction and its inputs have not changed. As soon as one layer changes, every layer after it must be rebuilt. That single rule dictates the order of your Dockerfile:
$ echo "version 2" > app.txt # change one file
$ docker build -t myapp:2 .
=> [1/3] FROM docker.io/library/alpine:3.20 DONE
=> [2/3] RUN apk add --no-cache curl CACHED
=> [3/3] COPY app.txt /app/app.txt DONE
The expensive apk add was cached, because nothing above it changed. Now imagine the COPY came first: the package install would rerun on every single code change. This is why the Dockerfile in section 5.4 copies composer.json and composer.lock on their own, installs dependencies, and only then copies the application. Dependencies change rarely, code changes constantly, so the slow step should sit above the fast one.
The second layer rule is the one that produces mysteriously enormous images. A later layer cannot make an earlier layer smaller. Deleting a file only records a deletion in the new layer; the bytes are still in the one below:
# Dockerfile.bad: two instructions, two layers
FROM alpine:3.20
RUN dd if=/dev/zero of=/big.bin bs=1M count=100
RUN rm /big.bin
# Dockerfile.good: one instruction, one layer
FROM alpine:3.20
RUN dd if=/dev/zero of=/big.bin bs=1M count=100 && rm /big.bin
$ docker images
layerdemo:bad 113MB
layerdemo:good 7.81MB # same result, no /big.bin in either
$ docker history layerdemo:bad --format 'table {{.CreatedBy}}\t{{.Size}}'
RUN /bin/sh -c rm /big.bin 0B <- the deletion
RUN /bin/sh -c dd if=/dev/zero of=/big.bin ... 105MB <- still there
Both images have no /big.bin. One of them is fifteen times larger. Chain the cleanup into the same RUN with &&, and use apt-get clean && rm -rf /var/lib/apt/lists/* or apk add --no-cache in the same instruction that installed the packages.
6.2 The Build Context and .dockerignore
When you run docker build ., that dot is a directory that gets packed up and handed to the build engine before the Dockerfile is even read. If your project contains node_modules, a .git history and a folder of client photographs, all of it is sent, every time.
A .dockerignore file, with the same syntax as .gitignore, keeps it out:
.git
node_modules
vendor
*.log
*.sql
.env
tmp/
cache/
The effect is immediate, and it applies to the image too, because COPY . /app can only copy what was sent:
$ docker images
ctxdemo:a 91.7MB # no .dockerignore
ctxdemo:b 7.81MB # with node_modules excluded
Add .env to that file on the first day. Copying a whole project directory into an image is the most common way credentials end up published in a registry.
6.3 Multi-Stage Builds
Most applications need one set of tools to build and a completely different, much smaller set to run. A compiler, a package manager and a test suite have no business in a production image. Multi-stage builds let you use them and then leave them behind:
FROM alpine:3.20 AS build # stage 1: everything you need to build
RUN apk add --no-cache build-base
COPY hello.c /src/hello.c
RUN gcc -static -o /hello /src/hello.c
FROM alpine:3.20 # stage 2: a clean image
COPY --from=build /hello /usr/local/bin/hello
CMD ["hello"]
Only the last stage becomes the image. Everything in build is discarded once its output has been copied out:
$ docker images
multidemo:single 230MB # compiler included
multidemo:multi 7.88MB # just the binary
$ docker run --rm multidemo:multi
hello
The same pattern works for every stack: node to run the bundler and nginx to serve the result, composer to resolve dependencies and php-fpm to run them, golang to compile and scratch to hold the binary. Smaller images pull faster, start faster, and have far less inside them for a scanner to find a CVE in.
6.4 Running as a Non-Root User
Containers run as root unless you say otherwise. Since there is no user namespace by default, that root is the host's root wearing a hat.
Fix it in the image, so nobody has to remember a flag:
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node # everything after this runs as node
CMD ["node", "server.js"]
Or at run time, which is the right approach for a bind-mounted development setup:
$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/app" -w /app \
node:22-alpine npm install
Add the other standard hardening flags once you are comfortable:
$ docker run -d \
--user 1000:1000 \
--read-only \ # the container filesystem is immutable
--tmpfs /tmp \ # ... except for a RAM-backed /tmp
--cap-drop ALL \ # remove every Linux capability
--security-opt no-new-privileges \
myapp:1.0
For a genuinely rootless setup, look at Docker's rootless mode, which runs the daemon itself as an unprivileged user, or at Podman, which is daemonless and rootless by design.
6.5 Capabilities, seccomp and the --privileged Trapdoor
Section 6.4 used --cap-drop ALL and --security-opt without saying what they act on. Three separate kernel mechanisms are involved, and Docker already applies two of them for you.
Capabilities split the old all-or-nothing root privilege into individual powers: bind a low port, change file ownership, load a kernel module, and so on. Root in a container is not full root, because Docker keeps only fourteen of the forty-one:
$ docker run --rm alpine:latest grep CapEff /proc/self/status
CapEff: 00000000a80425fb # 14 capabilities
$ docker run --rm alpine:latest sh -c 'apk add -q libcap; capsh --print | head -1'
Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,
cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,
cap_mknod,cap_audit_write,cap_setfcap=ep
$ docker run --rm --cap-drop ALL alpine:latest grep CapEff /proc/self/status
CapEff: 0000000000000000 # none at all
Seccomp filters which system calls the process may make at all. Docker applies a default profile that blocks the dangerous ones, and you can see the filter is active:
$ docker run --rm alpine:latest grep Seccomp: /proc/self/status
Seccomp: 2 # 2 = a filter is loaded
$ docker run --rm alpine:latest mount -t tmpfs none /mnt
mount: permission denied (are you root?) # it IS root; the filter said no
AppArmor (or SELinux on Red Hat systems) adds a mandatory access control profile on top, restricting which files and interfaces the process can touch regardless of its user ID:
$ docker run --rm alpine:latest cat /proc/self/attr/current
docker-default (enforce)
All three are on by default, which is a large part of why a container is safer than running the same binary as root on the host. Now watch what one flag does to them:
$ docker run --rm --privileged alpine:latest grep CapEff /proc/self/status
CapEff: 000001ffffffffff # all 41 capabilities
$ docker run --rm --privileged alpine:latest grep Seccomp: /proc/self/status
Seccomp: 0 # the filter is GONE
$ docker run --rm --privileged alpine:latest sh -c 'mount -t tmpfs none /mnt && echo OK'
OK # the call that was refused now works
--privileged does not simply "give more permissions". It hands back every capability and silently switches seccomp off, and it exposes the host's devices:
$ docker run --rm alpine:latest sh -c 'ls /dev | wc -l'
15 # a safe, minimal set
$ docker run --rm --privileged alpine:latest sh -c 'ls /dev | wc -l'
293
$ docker run --rm --privileged alpine:latest ls /dev/ | grep nvme
nvme0
nvme0n1
nvme0n1p1 # the host's raw disk partitions
A privileged container can read and write the host's disks directly, whatever the filesystem permissions on them say. It is not a container in any meaningful security sense; it is a root shell with extra steps. People reach for it because something failed with "operation not permitted" and --privileged makes the error go away. Almost always the right fix is one capability:
$ docker run --cap-add NET_ADMIN ... # manage interfaces and routes
$ docker run --cap-add SYS_PTRACE ... # run a debugger inside
$ docker run --device /dev/ttyUSB0 ... # one device, not all of them
Find out which capability you actually need, add that one, and leave the other forty alone. Reserve --privileged for the genuine cases, such as Docker-in-Docker in a CI runner, and treat it the way you would treat handing out the root password.
6.6 Healthchecks
"Running" and "working" are different things. A container whose application has deadlocked is still running perfectly. A healthcheck asks the application itself:
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost/health || exit 1
$ docker ps --format 'table {{.Names}}\t{{.Status}}'
NAMES STATUS
comp-web-1 Up 8 seconds (healthy)
$ docker inspect -f '{{.State.Health.Status}}' comp-web-1
healthy
--start-period is the flag people forget. Without it, a database that needs forty seconds to come up is marked unhealthy long before it has had a chance. Failures during the start period do not count against the retry budget.
Healthchecks pay for themselves in Compose, where another service can wait for a real ready state instead of a guess:
depends_on:
db:
condition: service_healthy # not just "started"
Plain depends_on: [db] only waits for the container to start, which for a database is roughly the moment it begins its own startup work. That is the cause of most "connection refused on first boot, fine after a restart" reports.
6.7 Resource Limits
By default a container may use every core and all the memory on the machine, which means one runaway process can take down everything else on the server:
$ docker run -d --memory=512m --cpus=1.5 --pids-limit=200 myapp:1.0
These are cgroup settings, and you can read them from inside the container:
$ docker run --rm --memory=64m alpine:3.20 cat /sys/fs/cgroup/memory.max
67108864 # 64 MB, in bytes
$ docker run --rm alpine:3.20 cat /sys/fs/cgroup/memory.max
max # no limit at all
$ docker run --rm --cpus=1.5 alpine:3.20 cat /sys/fs/cgroup/cpu.max
150000 100000 # 150ms of CPU per 100ms period
These files are cgroup v2, the unified hierarchy that every current distribution uses. On an older host you would find a tree of per-controller directories such as /sys/fs/cgroup/memory/ instead. Check which one you are on with docker info | grep "Cgroup Version".
Watch usage live with docker stats. When a container does hit its memory limit, the kernel's OOM killer ends it, and the result looks like this:
$ docker run -d --name oomtest --memory=32m --memory-swap=32m alpine:latest \
sh -c 'tail /dev/zero' # allocate until it cannot
$ docker inspect -f 'exit={{.State.ExitCode}} oomkilled={{.State.OOMKilled}}' oomtest
exit=137 oomkilled=true
Now compare that with the exit code from section 7.2, where a container that simply ignored SIGTERM was killed after the ten-second grace period:
exit=137 oomkilled=false # SIGKILLed by docker stop
exit=137 oomkilled=true # SIGKILLed by the kernel, out of memory
Exit code 137 alone cannot tell you which happened. It only means "killed by signal 9", and both causes end that way. The OOMKilled field is the discriminator, and it is the first thing to check when a container disappears without an application error in its logs. The host side of the story is in dmesg or journalctl -k, where the kernel records which process it chose and why. A container that vanishes silently under load has usually not crashed at all; it has been shot.
6.8 Where the Logs Actually Go
The default logging driver writes JSON to a file on the host, one file per container:
$ docker inspect -f '{{.LogPath}}' web
/var/lib/docker/containers/ca3b0f8b.../ca3b0f8b...-json.log
$ docker inspect -f '{{json .HostConfig.LogConfig}}' web
{"Type":"json-file","Config":{}}
Look at that empty Config. There is no size limit and no rotation. A chatty application on a long-running container will fill the disk, and because the file lives under /var/lib/docker rather than /var/log, normal log rotation never touches it. Cap it:
$ docker run -d --log-opt max-size=10m --log-opt max-file=3 myapp:1.0
Better, set it once for the whole machine in /etc/docker/daemon.json and restart the daemon:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Other drivers exist for shipping logs elsewhere: journald puts container output into the systemd journal so journalctl can read it, syslog forwards to a log server, and none throws it away for containers whose output you never want.
6.9 Cleaning Up
Docker never deletes anything on its own. Stopped containers, dangling images, unused volumes and build cache all accumulate quietly until a deployment fails at 2am with "no space left on device". Ask what it is holding:
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 276 76 130.1GB 76.81GB (59%)
Containers 124 2 1.925GB 1.925GB (99%)
Local Volumes 60 7 73.07GB 72.25GB (98%)
Build Cache 1452 0 14.08GB 14.08GB
That is a real developer machine, and it is holding over 160 GB it does not need. The commands, from safest to most destructive:
$ docker container prune # remove all stopped containers
$ docker image prune # remove dangling (untagged) images
$ docker builder prune # remove build cache
$ docker system prune # all of the above, in one go
$ docker system prune -a # ... plus every image not used by a
# running container. Re-pull time.
$ docker volume prune # DANGEROUS: this is your data
$ docker system df -v # itemised, before you decide
Learn the difference between prune and prune -a before you type either on a server. Plain prune removes untagged leftovers. With -a, any image without a running container goes, including the ones your stopped services need to start again.
And treat docker volume prune as a separate category. Everything else costs you download time. That one costs you data. It is also why docker system prune deliberately leaves volumes alone unless you add --volumes.
7. Something Most Users Do Not Know
7.1 Your Container Is in the Host's Process List
Section 1.3 showed it, but the consequences are worth spelling out. A container is a host process, so every ordinary Linux tool works on it. You do not need docker exec to investigate a container that has stopped responding:
$ docker inspect -f '{{.State.Pid}}' web
438607
$ sudo ls -l /proc/438607/root/ # the container's filesystem, from the host
$ sudo cat /proc/438607/environ | tr '\0' '\n'
$ sudo strace -p 438607 # trace its system calls
$ sudo nsenter -t 438607 -a sh # enter its namespaces without Docker
That last one is the emergency door. If the Docker daemon itself is unwell, nsenter still puts you inside the container, because the namespaces belong to the kernel and not to Docker.
7.2 PID 1 Ignores SIGTERM, Which Is Why Stopping Takes Ten Seconds
Time a stop:
$ docker run -d --name demo alpine:3.20 sleep 600
$ time docker stop demo
real 0m10.093s
$ docker inspect -f 'exit={{.State.ExitCode}}' demo
exit=137 # 128 + 9: it was SIGKILLed
docker stop sends SIGTERM, waits ten seconds, then sends SIGKILL. Almost every container takes the full ten, and the reason is a Linux rule that has nothing to do with Docker: the kernel does not apply default signal actions to PID 1. An ordinary process that ignores SIGTERM dies anyway, because the default action is to terminate. PID 1 has no default action, so a signal it does not explicitly handle is simply discarded.
$ docker run -d --name s1 alpine:3.20 sleep 300
$ docker kill -s TERM s1
$ docker inspect -f 'running={{.State.Running}}' s1
running=true # it shrugged off the signal
Give the same process a handler and the ten seconds disappear:
$ docker run -d --name g1 alpine:3.20 sh -c \
'trap "echo caught SIGTERM; exit 0" TERM; while true; do sleep 1; done'
$ time docker stop g1
real 0m1.055s
$ docker logs g1
caught SIGTERM
$ docker inspect -f 'exit={{.State.ExitCode}}' g1
exit=0 # a clean shutdown
This is not a cosmetic issue. Ten seconds of delay on every container is annoying; a database that is SIGKILLed instead of shut down cleanly is a corrupted database. If you write the application, handle SIGTERM. If you do not, section 7.3 has the general fix. In the meantime you can shorten or lengthen the grace period with docker stop -t 30, or set it per image with STOPSIGNAL.
7.3 Zombies, and What --init Is Actually For
PID 1 has a second job besides handling signals: adopting orphaned processes and reaping them when they exit. A real init does this. sleep, node and python do not. Watch a zombie appear:
$ docker run -d --name s2 alpine:3.20 sh -c 'sleep 300 & sleep 300'
$ docker exec s2 ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 300
7 root 0:00 sleep 300
$ docker exec s2 sh -c 'kill -TERM 7'
$ docker exec s2 ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 300
7 root 0:00 [sleep] # defunct, never reaped
Process 7 is dead but its entry cannot be removed, because PID 1 never calls wait() for it. In a short-lived container this is harmless. In a long-running one that spawns child processes, the table fills up. Notice also that the SIGTERM which PID 1 ignored killed process 7 immediately: the same signal, the same binary, a different outcome purely because of the process ID.
The fix is one flag:
$ docker run --rm --init alpine:3.20 ps -ef
PID USER TIME COMMAND
1 root 0:00 /sbin/docker-init -- ps -ef
7 root 0:00 ps -ef
--init puts a tiny real init (tini) at PID 1. It reaps zombies and it forwards signals to your process, so the process gets SIGTERM at a PID where the default action still applies. In Compose it is init: true. Use it for anything that starts child processes, and for any image you did not write.
7.4 Membership of the docker Group Is Root Access
Adding yourself to the docker group so you can skip sudo is the first thing most people do. It is worth understanding what you granted yourself.
$ id -nG
pe7er adm cdrom sudo dip plugdev users lpadmin docker
$ ls /root
ls: cannot open directory '/root': Permission denied
$ docker run --rm -v /:/host:ro alpine:3.20 ls /host/root
Desktop
snap
The same user, one second apart, one command. The daemon runs as root, it happily mounts any host path you name, and the container is root, so it reads anything. Replace :ro with write access and you can edit /etc/sudoers, add an SSH key to /root/.ssh, or install a systemd unit.
Docker documents this honestly: the docker group grants privileges equivalent to root. It is a reasonable trade on your own laptop. It is a decision to make consciously on a shared server, where "give the deploy user Docker access" and "give the deploy user root" are the same sentence. The mitigations are rootless mode, or Podman, or requiring sudo docker so that the privilege is at least visible in the audit log.
There is a fourth mitigation, and it is the one that closes the specific hole from section 1.3, where the container's user namespace turned out to be the host's. The daemon can be told to give containers their own:
$ cat /etc/subuid
pe7er:100000:65536 # 65536 IDs, starting at 100000
# /etc/docker/daemon.json
{ "userns-remap": "pe7er" }
With that set, root inside a container (UID 0) is mapped to UID 100000 on the host. A container that breaks out finds itself as an unprivileged account that owns nothing, and files it writes to a bind mount are owned by 100000 rather than by root. The price is real, which is why it is not the default: bind mounts need their ownership adjusted to match the mapped range, some images that expect a specific UID break, and containers cannot share namespaces with the host. Turn it on for a server that runs other people's containers. Leave it off for a development laptop, and simply know that root in the container is root.
7.5 Build Arguments and ENV Live in the Image Forever
A common pattern for a private dependency is to pass a token at build time. It does not do what people think:
FROM alpine:3.20
ARG API_TOKEN
RUN echo "using token $API_TOKEN" > /tmp/build.log
ENV DB_PASSWORD=hunter2
$ docker build --build-arg API_TOKEN=s3cr3t-value -t leakdemo:1 .
$ docker history --no-trunc leakdemo:1 --format '{{.CreatedBy}}'
ENV DB_PASSWORD=hunter2
RUN |1 API_TOKEN=s3cr3t-value /bin/sh -c echo "using token $API_TOKEN" ...
ARG API_TOKEN=s3cr3t-value
$ docker image inspect leakdemo:1 -f '{{json .Config.Env}}'
["PATH=/usr/local/sbin:...","DB_PASSWORD=hunter2"]
Both secrets are in the image metadata, readable by anyone who pulls it, and docker history does not even require running the container. Deleting the file in a later layer changes nothing, because the value is in the build instruction, not in the filesystem.
BuildKit has a proper answer. A secret mount makes the value available during one RUN and leaves no trace:
FROM alpine:3.20
RUN --mount=type=secret,id=api_token \
echo "token length: $(wc -c < /run/secrets/api_token)" > /tmp/build.log
$ docker build --secret id=api_token,src=token.txt -t leakdemo:2 .
$ docker history --no-trunc leakdemo:2 --format '{{.CreatedBy}}'
RUN /bin/sh -c echo "token length: $(wc -c < /run/secrets/api_token)" ...
# no value anywhere
$ docker run --rm leakdemo:2 sh -c 'cat /tmp/build.log; ls /run/secrets'
token length: 13 # it was readable during the build
ls: /run/secrets: No such file or directory # and is gone now
Runtime secrets are a separate problem with a separate answer: pass them with --env-file or Compose secrets, keep the file out of the image with .dockerignore, and never with ENV.
7.6 The Default Bridge Has No DNS, on Purpose
Section 5.2 showed that ping n1 fails on the default bridge and works on a user-defined network. This surprises people because old tutorials use --link, a deprecated flag that wrote entries into /etc/hosts.
The modern replacement is not a flag, it is a network. Docker runs an embedded DNS server at 127.0.0.11 for every container attached to a user-defined network, and it resolves container names and Compose service names automatically. The default bridge is left without it for backward compatibility with setups built before user-defined networks existed. The practical rule is short: create a network, or use Compose, and never use the default bridge for anything with more than one container.
7.7 A Published Port Can Walk Straight Past Your Firewall
You configured ufw to allow only ports 22, 80 and 443. You start a database container with -p 3306:3306 for a quick test. Your database is now on the internet.
Docker manipulates iptables directly to route published ports to containers, and those rules are evaluated before the ones ufw manages. This is documented behaviour, not a bug, and it catches experienced administrators because everything about it looks correct: ufw status reports exactly the policy you wrote, and the port is open anyway.
Three defences, in order of how much you should prefer them:
- Bind to localhost.
-p 127.0.0.1:3306:3306only ever listens on the loopback interface, so there is nothing for a firewall to block. This is the right answer for almost every case, especially anything behind a reverse proxy. - Do not publish at all. Containers on the same user-defined network reach each other on the container port directly. A database that only a web container talks to needs no
-pline whatsoever. - Change the daemon's behaviour with
"iptables": falsein/etc/docker/daemon.json, and take over the routing rules yourself. Powerful, and easy to get wrong. Read Docker's packet filtering documentation before you do.
The habit worth building: after starting anything with -p, run ss -tlnp | grep LISTEN and look at the addresses. 0.0.0.0 means everyone.
7.8 An Image Is Just a Tar File Full of Tar Files
There is no magic in an image format. Save one and look:
$ docker save alpine:3.20 -o alpine.tar
$ tar -tf alpine.tar
blobs/
blobs/sha256/08bc4e534116aa76b16015484b82eac51f9a593416feae9296c8a2d4bb7aa4a2
blobs/sha256/27698c48e88c1988da5594022f53451c70e28738541b64a76b994adf0f357d57
index.json
manifest.json
oci-layout
Each blob is either a compressed tar of one filesystem layer or a small JSON document describing the image. That is the entire OCI image specification in spirit: a manifest, a config, and some tarballs, all addressed by their SHA-256 hash. It is why docker save and docker load can move an image between machines with no registry at all, which is useful on an air-gapped server:
$ docker save myapp:1.0 | gzip > myapp-1.0.tar.gz
$ scp myapp-1.0.tar.gz server:/tmp/
$ ssh server 'gunzip -c /tmp/myapp-1.0.tar.gz | docker load'
It is also why content-addressed pinning works. Every image has a digest, and unlike a tag, a digest cannot move:
$ docker image inspect alpine:3.20 -f '{{json .RepoDigests}}'
["alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"]
$ docker run alpine@sha256:d9e853e87e55... # exactly this image, always
7.9 latest Does Not Mean Latest
latest is a tag like any other. It has no special meaning to Docker, it is simply the tag used when you do not name one, and it points at whatever was last pushed with that label. Once it is on your disk, it stays exactly as it was:
$ docker image inspect alpine:latest -f '{{.RepoTags}} {{.Created}}'
[alpine:latest] 2025-02-14T03:28:36Z # pulled once, long ago
$ docker pull alpine:3.20
3.20: Pulling from library/alpine # today's 3.20.10
On that machine alpine:latest is over a year older than alpine:3.20, which is not a contradiction once you accept that latest means nothing. Two consequences follow. Never trust latest to be current; run docker pull or docker compose pull deliberately. And never deploy latest, because two servers that pulled at different times are running different software while both report the same tag.
7.10 Knowing Where Docker Stops
Docker is a good tool with clear edges, and part of using it well is recognising the point where something else takes over.
| When you need | Look at |
|---|---|
| Containers across many machines, self-healing, rolling updates | Kubernetes, or Docker Swarm for something much simpler |
| No daemon, no root, systemd-native units | Podman, with podman generate systemd |
| Building images without a daemon, inside CI | Buildah, Kaniko, or docker buildx |
| Stronger isolation than namespaces provide | gVisor, Kata Containers, or an actual virtual machine |
| Reproducible development environments, not deployment | Nix, or a devcontainer specification |
| Running a full system image, not one process | systemd-nspawn, or LXC/LXD |
| Scanning an image for known vulnerabilities | Trivy, Grype, or the docker scout CLI plugin |
And one honest limit: Docker solves "it works on my machine" for the software, not for the data or the configuration. A container that runs perfectly and points at the wrong database is still an outage.
Back to top8. Best Practices
- Pin your base images.
FROM php:8.3-fpm-alpine, neverFROM php. For anything you deploy, pin the digest as well, so the image cannot change under you between two servers. - Never deploy
latest. Tag with a version, a date or a commit hash. When something breaks at 2am you need to know what is running, andlatestcannot tell you. - Order the Dockerfile from least to most likely to change. Base image, system packages, dependency manifests, dependency install, then application code. Every minute of build time you save, you save on every commit.
- Chain cleanup into the same
RUN.apk add --no-cache, orapt-get install && apt-get clean && rm -rf /var/lib/apt/lists/*. A separateRUN rmdeletes nothing from the image. - Write a
.dockerignorebefore your first build. Start with.git,node_modules,vendor,*.log,*.sqland.env. - Use multi-stage builds. Compilers, test suites and package managers belong in the build stage, not in the image you ship.
- Add
USERto every Dockerfile. Root is the default, and without a user namespace that root is the host's root. - One concern per container. Not "one process", which is too strict, but do not put nginx, PHP-FPM and MariaDB in one image. Separate containers scale, restart and get replaced independently.
- Log to standard output. That is what
docker logs, Compose and every log shipper read. Cap the log withmax-sizeandmax-file, ideally once in/etc/docker/daemon.json. - Put persistent data in a named volume, and back the volume up. A container is disposable by design; the data inside it must not be.
- Bind published ports to
127.0.0.1unless the world genuinely needs them. Docker's own firewall rules run beforeufw, so the address you bind to is the real control. - Handle SIGTERM, or add
--init. Otherwise every stop takes ten seconds and ends in SIGKILL, which for a database means a dirty shutdown. - Set a healthcheck with a
start_period, and depend onservice_healthyrather than on the container merely having started. - Set memory and CPU limits on a shared server. Without them, one container can starve everything else on the machine.
- Never reach for
--privilegedto make an error go away. It restores every capability and disables seccomp. Find the one capability you need and add that, or pass the single--deviceyou need. - Keep secrets out of images. Not
ENV, not--build-arg. Use BuildKit secret mounts at build time and an environment file or Compose secrets at run time. - Use Compose as soon as you have two containers. The file is documentation, version control and a repeatable setup all at once.
- Prune on a schedule, not in a panic. A weekly
docker system prune -fanddocker builder prune -fis fine. Leave-aand--volumesfor decisions you make on purpose. - Read the reference for the thing you are about to type.
$ docker --help # top-level command list
$ docker run --help # the long one; worth reading once
$ docker compose --help
$ man docker-run # if the man pages package is installed
$ docker system df -v # what is actually on this disk
$ docker inspect <name> # everything Docker knows about it
The online references at docs.docker.com for the Dockerfile instructions and the Compose file format are the two pages worth bookmarking. Both change more often than a man page does.
9. Common Mistakes
9.1 Myth Versus Reality
| Myth | Reality |
|---|---|
| "A container is a lightweight virtual machine." | It is one process on your kernel, fenced off by namespaces. There is no second kernel and no boot. |
| "Containers are isolated, so root inside is harmless." | Without a user namespace, root inside is UID 0 outside. It writes root-owned files onto your bind mounts and it is one misconfiguration away from the host. |
"I am in the docker group, not in sudo, so I am unprivileged." |
The docker group is root. One -v /:/host and the whole filesystem is yours (section 7.4). |
"EXPOSE 80 publishes port 80." |
It documents the port. Only -p at run time publishes anything. |
"-p 8080:80 is safe because ufw only allows 22, 80 and 443." |
Docker writes iptables rules that are evaluated before ufw's. The port is open. Bind to 127.0.0.1 instead. |
"latest is the newest version." |
It is a label pointing at whatever was last pushed, and your local copy can be a year old. |
"RUN rm -rf /big-thing made the image smaller." |
A later layer cannot shrink an earlier one. The bytes are still there; you added a deletion record (section 6.1). |
"--build-arg TOKEN=... keeps the token out of the image." |
docker history prints it. Use a BuildKit secret mount (section 7.5). |
"docker stop shuts the application down cleanly." |
Only if the application handles SIGTERM. Otherwise it is SIGKILLed after ten seconds, exit code 137. |
"depends_on waits until the database is ready." |
It waits until the container has started. Use condition: service_healthy with a real healthcheck. |
| "My data is in the container, so it is saved." | It survives stop and start. It does not survive rm, and docker compose up recreates containers routinely. |
| "Containers on one host can find each other by name." | Only on a user-defined network. The default bridge has no DNS at all (section 5.2). |
"localhost in my config points at the other container." |
localhost inside a container is that container. Use the service name. |
| "Kubernetes dropped Docker, so my images are obsolete." | Kubernetes dropped dockershim, a translation layer. Your images are OCI images and run everywhere. |
"docker system prune is a safe cleanup." |
Plain prune is fairly safe. -a removes every image without a running container, and --volumes removes your data. |
"--privileged just grants a few extra permissions." |
It restores all 41 capabilities, turns seccomp off, and exposes the host's devices including its raw disks (section 6.5). |
| "Exit code 137 means the memory limit was hit." | 137 means killed by signal 9. That is also what docker stop does after ten seconds. Only OOMKilled tells them apart. |
| "Writing a few bytes to a file only costs a few bytes." | If the file came from an image layer, OverlayFS copies the whole file up first. Two bytes into a 200 MB file cost 210 MB (section 1.2). |
"The docker command creates my containers." |
It sends an HTTP request. dockerd, containerd, a shim and runc do the work, and runc has already exited by the time your program runs. |
| "Docker guarantees my application behaves the same everywhere." | It guarantees the same filesystem and the same process. The kernel, the CPU architecture, the mounted data and the environment variables are all still yours to get right. |
9.2 Other Traps to Avoid
- Reversing the port mapping.
-p 80:8080when you meant-p 8080:80. Host first, container second, every time. - Bind-mounting over a directory the build populated. Mounting your project onto
/apphides thenode_modulesorvendorthatnpm ciorcomposer installcreated during the build. Mount a volume over the dependency directory as well, or install at run time. - Editing files inside a running container. They vanish the next time the container is recreated, and
docker compose uprecreates containers whenever the configuration changes. Change the image or the mounted source. - Using
docker committo save your work. It produces an image nobody can rebuild or review. Put the change in the Dockerfile. - Running a database on a bind mount on macOS or Windows. The filesystem translation layer makes it slow and occasionally unreliable. Use a named volume.
- Forgetting that
COPY . .copies your.env. If it is not in.dockerignore, it is in the image, and if you push the image it is public. - Leaving containers named by Docker.
--namecosts three seconds and makes every later command, log line and script readable. - Trusting an image because it has many pulls. Prefer official images and verified publishers, read the Dockerfile if the project publishes it, and scan what you deploy.
- Assuming an image runs on any machine. An
amd64image will not run on an ARM server without emulation. Check withdocker image inspect -f '{{.Architecture}}', and build multi-platform images withdocker buildx build --platform linux/amd64,linux/arm64. - Letting the JSON log file grow forever. It lives under
/var/lib/docker, sologrotatenever sees it, and it will fill the disk on a long-running container. - Running
apt-get upgradein a Dockerfile. It makes the build non-reproducible and fights with the base image's own update cycle. Pin a newer base image instead. - Mounting
/var/run/docker.sockinto a container. That container now has root on the host. Sometimes it is genuinely required, for a CI runner or a reverse proxy that watches for new containers. Treat it as the security decision it is, and mount it read-only where the tool allows. - Ignoring the build cache when debugging. If a build produces stale results,
docker build --no-cachetells you in one run whether the cache was lying to you. - Storing state in the container filesystem "just for now". Uploads, sessions and SQLite files written outside a volume disappear on the next deployment, and the next deployment is always sooner than you think.
10. Summary
Docker is a thin, well-designed layer over kernel features that already existed. Everything that feels strange about it makes sense once you accept that a container is a process, not a machine.
- A container is one Linux process, given a private view of the system by namespaces and a budget by cgroups. It shares your kernel;
pson the host can see it. - An image is a read-only stack of layers plus configuration. A container is an instance of one with a thin writable layer. A registry stores images.
- The
dockercommand is only a client. The daemon does the work, over/var/run/docker.sock, as root. Membership of thedockergroup is therefore root access. - The name comes from dock workers, and the metaphor is the shipping container: standardise the box, not the contents. The whale is Moby Dock.
- Docker did not invent containers. It made 2008-era kernel features usable in one command, and the OCI standard it started means your image runs under Podman, containerd and Kubernetes too.
- Learn six commands and you can work:
run,ps,logs,exec,stop,rm. Use--rm -itfor throwaway work and-d --namefor anything that stays. docker logsshows only what PID 1 wrote to stdout and stderr. A log file inside the container is invisible to it.- Persist data in a named volume. A bind mount is for source code and configuration, it hides whatever the image had at that path, and files it creates are owned by root unless you pass
--user. - Container name resolution works on a user-defined network, never on the default bridge. Compose creates one for you, which is half the reason to use it.
- Layer order is build speed. Dependencies before code, and cleanup in the same
RUN, because a later layer can never shrink an earlier one: 113 MB versus 7.81 MB for the same result. - A
.dockerignorefile and a multi-stage build are the two changes with the biggest effect on image size. 230 MB became 7.88 MB in section 6.3. - PID 1 ignores signals it does not handle, which is why
docker stoptakes ten seconds and ends in exit code 137. Handle SIGTERM, or run with--init, which also reaps the zombies that PID 1 otherwise leaves behind. - Secrets passed as
--build-argor set withENVare printed bydocker history. Use a BuildKit--mount=type=secretinstead. -p 8080:80binds0.0.0.0and Docker's firewall rules run beforeufw's. Write-p 127.0.0.1:8080:80, or do not publish at all.latestis a label, not a version. Pin tags, and pin digests for anything you deploy.- Under the CLI sit
dockerd,containerd, a per-container shim andrunc.runcexits once the namespaces exist, and the shim answers toinit, which is why the stack survives the daemon and why Kubernetes could dropdockershimwithout breaking a single image. - The filesystem is OverlayFS: read-only lower layers, one writable upper layer, a merged view. Writing to a file from an image layer copies the whole file up first, which is a real cost on large files and another reason data belongs in a volume.
- Docker already applies three protections you did not ask for: 14 of 41 capabilities, a seccomp filter, and an AppArmor profile.
--privilegedremoves all three at once and hands over the host's disks. Add one capability instead. - Exit code 137 means "killed by signal 9" and nothing more.
OOMKilledis what separates a memory limit from an unhandleddocker stop. - Docker never cleans up after itself.
docker system dfregularly, prune deliberately, and remember that--volumesdeletes data while everything else only costs a download.
This is the quick reference worth keeping:
RUN AND INSPECT
docker run --rm -it IMAGE sh throwaway container with a shell
docker run -d --name web -p 127.0.0.1:8080:80 IMAGE
docker ps / docker ps -a running / all containers
docker logs -f --tail 50 NAME follow the last 50 lines
docker exec -it NAME sh shell inside a RUNNING container
docker inspect NAME everything Docker knows
docker stats live CPU and memory per container
docker top NAME its processes, as the host sees them
LIFECYCLE
docker stop NAME SIGTERM, then SIGKILL after 10s
docker stop -t 30 NAME give it 30 seconds instead
docker start / restart / kill NAME
docker rm -f NAME stop and delete (writable layer lost)
IMAGES
docker pull IMAGE:TAG tags move; pull deliberately
docker build -t app:1.0 . the "." is the build CONTEXT
docker build --no-cache -t app:1.0 .
docker history --no-trunc IMAGE every layer, and every secret in one
docker image inspect IMAGE -f '{{json .RepoDigests}}'
docker save IMAGE | gzip > img.tar.gz move an image without a registry
gunzip -c img.tar.gz | docker load
DATA AND NETWORK
docker volume create|ls|inspect|rm NAME
-v myvol:/var/lib/mysql named volume: prefilled from the image
-v "$PWD:/app" bind mount: HIDES the image's content
--user "$(id -u):$(id -g)" stop creating root-owned files
docker network create mynet name resolution needs a user network
--add-host=host.docker.internal:host-gateway reach the host from inside
COMPOSE
docker compose up -d / down down keeps named volumes
docker compose down -v ... and this deletes them
docker compose ps / logs -f SERVICE / exec SERVICE sh
docker compose pull actually fetch newer images
HARDENING
--user 1000:1000 --read-only --tmpfs /tmp
--cap-drop ALL --security-opt no-new-privileges
--cap-add NET_ADMIN add ONE capability, never --privileged
--device /dev/ttyUSB0 one device, never --privileged
--memory=512m --cpus=1.5 --pids-limit=200
--init real PID 1: reaps zombies, forwards
signals, ends the 10-second stop
--log-opt max-size=10m --log-opt max-file=3
CLEANUP (increasing danger)
docker system df what is on this disk
docker container prune stopped containers
docker image prune dangling images only
docker builder prune build cache
docker system prune the three above
docker system prune -a every image without a running container
docker volume prune YOUR DATA
DIGGING UNDERNEATH
docker inspect -f '{{.State.Pid}}' NAME its PID on the host
docker inspect -f '{{json .GraphDriver}}' NAME its overlay2 directories
docker ps -s writable-layer size (copy-up shows here)
docker run --rm IMAGE grep CapEff /proc/self/status
docker info | grep -E "Cgroup Version|Live Restore"
sudo nsenter -t PID -a sh enter it without the Docker daemon
lsns / systemd-cgls / dmesg namespaces, cgroups, OOM kills
exit 137 = killed by signal 9. Check OOMKilled to learn WHICH:
oomkilled=false => the 10-second docker stop
oomkilled=true => the kernel, out of memory
exit 143 = SIGTERM | 0.0.0.0 in docker ps = open to the network
Docker rewards a small amount of curiosity very quickly. The commands above cover almost everything most people ever need, and the parts that go wrong in production are nearly always one of the same handful: a port bound to 0.0.0.0, data written outside a volume, a log file with no size limit, or a container that never learned to shut down. If a containerised site is behaving in a way nobody can explain, those four are the first places worth looking.


Peter is a Joomla specialist and a Linux admin for fast, secure and scalable websites.












