A how-to manual: how Docker works, running and debugging containers, building small images, volumes and file ownership, networks, Compose, limits, logs, security, cleanup and troubleshooting, with every example run for real in a lab.
▶Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
docker: runs programs in containers, from images.
docker run -d --name web -p 8080:80 nginx:alpine
docker ps
docker logs web
docker compose up -d
Docker packages a program and everything it needs into an image, and runs that image as a container: an isolated process with its own file system, network and users that shares the host's kernel. It is how most services are now built, tested and shipped, and it is also where most of the surprises live: a container that exits at once, a stop that takes ten seconds, files owned by root, a port that is open although the firewall says closed, a disk filled by logs.
This guide goes from the first docker run to the parts that cause trouble, in the order you meet them: containers, images, builds, volumes, networks, Compose, limits, logs, security and cleanup, then the errors and what each exit code means. The older cheat sheet that stood on this page is replaced by this guide, and its one-line commands are in the cheat sheet section.
Every example ran for real on Ubuntu 24.04 with Docker Engine 29.1.3 (Ubuntu's docker.io 29.1.3-0ubuntu3~24.04.2), Compose 2.40.3 and Buildx 0.30.1, inside a lab with its own Docker daemon, so no real containers were touched. The lab had only two small images (alpine and nginx:alpine) and no registry access, so the commands that need a registry (pull, push, login, search) are shown and marked as not run, and so are the ones that need systemd, a second machine or a firewall change.
What Docker is, which one you have, and the lab
Docker runs programs in containers: isolated processes that share the host's kernel but see their own file system, network, process list and users. A container starts in a fraction of a second because it is a process, not a virtual machine.
The words, in the order you meet them:
image a read-only template: the file system and the default command. Built once, run many times.
container a running (or stopped) copy of an image, plus a thin writable layer on top.
registry a server that stores images (Docker Hub, GitHub Container Registry, your own).
volume storage that lives outside any one container and survives its removal.
network a virtual network that containers join; user-defined ones give name-based DNS.
engine the dockerd daemon. The docker command only talks to it over a socket.
compose a second tool that starts several containers from one YAML file.
Every example below ran for real in a lab: an Ubuntu 24.04 machine with Docker Engine 29.1.3 (package docker.io 29.1.3-0ubuntu3~24.04.2), Compose 2.40.3 and Buildx 0.30.1. The lab is itself a container with its own Docker daemon inside it (so the guide never touched anyone's real containers). Only two small images were loaded into it, alpine and nginx:alpine, so the examples that need a registry download are shown and marked as not run. Commands that need systemd or a second machine are marked the same way.
$ docker --version
Docker version 29.1.3, build 29.1.3-0ubuntu3~24.04.2
$ docker compose version
Docker Compose version 2.40.3+ds1-0ubuntu1~24.04.1
$ docker version --format 'client {{.Client.Version}}, server {{.Server.Version}}'
client 29.1.3, server 29.1.3
$ docker info --format 'storage driver: {{.Driver}}, cgroup: {{.CgroupVersion}}, containers: {{.Containers}}, images: {{.Images}}'
storage driver: overlayfs, cgroup: 2, containers: 0, images: 2
$ docker images
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
alpine:latest 294b683cb724 13MB 3.94MB
nginx:alpine df221db836e1 94.4MB 27.2MB
The first test of any installation is to run something small. alpine is a 13 MB image, and --rm deletes the container when it exits:
$ docker run --rm alpine echo "hello from a container"
hello from a container
$ docker run --rm alpine cat /etc/os-release | head -2
NAME="Alpine Linux"
ID=alpine
$ docker run --rm alpine uname -r
7.0.0-38-generic
$ uname -r
7.0.0-38-generic
The last two commands print the same kernel version. That is the whole idea: a container uses the host's kernel, and brings its own user space (here Alpine's, whatever the host runs).
The other tools you will hear about: Podman (a daemonless, rootless-first engine with a nearly identical docker-style command line; sudo apt install podman), containerd and nerdctl (what Kubernetes uses underneath), and Docker Desktop (a packaged Docker for Windows and macOS that runs Linux in a small virtual machine). This guide covers Docker Engine on Linux. Podman was not run in the lab.
Installing it and who may use it
On Ubuntu there are two common ways to get Docker:
sudo apt install docker.io docker-compose-v2 docker-buildx Ubuntu's own packages (what the lab uses). Updated with the system.
Docker's own apt repository (docker-ce) Newer releases sooner. Follow the current instructions at docs.docker.com/engine/install/ubuntu.
Pick one and not both: the packages conflict. Then check that the daemon runs:
$ sudo systemctl status docker --no-pager | head -5
$ sudo systemctl enable --now docker
(Not run: the lab has no systemd, its daemon is started by hand.)
Permission denied on the socket is the first error everybody meets. The docker command talks to /var/run/docker.sock, which belongs to root and the group docker:
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Oct 18 21:24 /var/run/docker.sock
$ id -nG | tr ' ' '\n' | grep -x docker
docker
$ docker ps # run as the user nobody
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
(exit status 1)
$ sudo usermod -aG docker "$USER" # then log out and in (or run: newgrp docker)
(The third command ran as the user nobody, who is not in the group, so the socket is closed to it.)
THE GROUP docker IS ROOT. Anyone in it can read and change any file on the host. Here is the proof, run by an ordinary user (ubuntu) who has no sudo:
$ id -un
ubuntu
$ cat /etc/shadow
cat: /etc/shadow: Permission denied
(exit status 1)
$ docker run --rm -v /etc/shadow:/s:ro alpine head -c 5 /s
root:
The first read is refused, the second one, through a container, is not. So put people in the docker group only if you would give them root, and look at rootless mode (dockerd-rootless-setuptool.sh install, not run) or Podman where that is not acceptable.
Run containers
docker run creates a container from an image and starts it. The pieces you will use daily:
docker run IMAGE COMMAND run COMMAND in a new container (the image's default command if you leave it out)
-d detached: run in the background and print the container ID
--name web a name you can use instead of the ID (names are unique)
-p 8080:80 publish: host port 8080 goes to container port 80
-e KEY=value set an environment variable (--env-file FILE for many)
--rm delete the container when it exits
-it interactive with a terminal, for a shell
--restart unless-stopped start again after a crash or a reboot
A web server in the background, reachable on port 8080:
$ docker run -d --name web -p 8080:80 nginx:alpine
a2795189f6387a50f98d1e6223fa2fd99008efe873052c7a314c466ef18c12eb
$ curl -sI localhost:8080 | head -2
HTTP/1.1 200 OK
Server: nginx/1.31.6
$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES IMAGE PORTS
web nginx:alpine 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
The 0.0.0.0:8080 means every network interface, so the whole network can reach it. On a server, publish on localhost only unless you mean otherwise (and see the firewall note below):
$ docker run -d --name private -p 127.0.0.1:8081:80 nginx:alpine
6b31af4743b67a692b30948a6de9c57c46b82f0e334d2a1a16df9659f5d35ca4
$ docker port private
80/tcp -> 127.0.0.1:8081
$ docker rm -f private
private
Docker writes its own iptables rules for published ports. They are evaluated before ufw and firewalld rules, so a port you publish can be open to the network even if ufw says it is closed. Bind to 127.0.0.1 and put a reverse proxy in front, or filter in the DOCKER-USER chain (see the networking section).
Environment variables and a one-off command:
$ docker run --rm -e GREETING=hello alpine sh -c 'echo "$GREETING from $(hostname)"'
hello from 9377579f2274
$ docker run --rm --env-file /dev/null alpine env | sort | head -4
HOME=/root
HOSTNAME=4d5d2bf1babd
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
A name can only be used once. The second run fails. A published port can only be used once too, and the failed container still exists (in the Created state) until you remove it:
$ docker run -d --name web alpine sleep 60
docker: Error response from daemon: Conflict. The container name "/web" is already in use by container "a2795189f6387a50f98d1e6223fa2fd99008efe873052c7a314c466ef18c12eb". You have to remove (or rename) that container to be able to reuse that name.
Run 'docker run --help' for more information
(exit status 125)
$ docker run -d --name other -p 8080:80 nginx:alpine
82af484610bf28e0874c894c4ea00ff8418b357dd019c56a40490f3322e22a9b
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint other (77fb766212e19e24a3a695c4570b0a12766b3e3a8542a7772022e5227922535a): Bind for 0.0.0.0:8080 failed: port is already allocated
Run 'docker run --help' for more information
(exit status 125)
$ docker rm -f other
other
An interactive shell needs a terminal (-it). In a script or a pipe there is no terminal, so leave -t off:
$ docker run --rm -it alpine sh
$ echo 'echo shell-works; exit' | docker run --rm -i alpine sh
shell-works
(The first line needs a real terminal and was not run here.) Restart policies decide what happens when the container exits or the daemon restarts:
$ docker run -d --name always --restart unless-stopped alpine sleep 300
5e3c8b207f90c0fb570c28e88c4c9d55a42501d99647c3f17eb942aab12daf5b
$ docker inspect -f '{{.Name}} policy={{.HostConfig.RestartPolicy.Name}} restarts={{.RestartCount}}' always
/always policy=unless-stopped restarts=0
$ docker update --restart no always
always
$ docker inspect -f 'policy={{.HostConfig.RestartPolicy.Name}}' always
policy=no
$ docker rm -f always
always
Manage containers: start, stop, and why stop sometimes takes ten seconds
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a2795189f638 nginx:alpine "/docker-entrypoint.…" 5 seconds ago Up 2 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp web
$ docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.State}}'
NAMES IMAGE STATE
web nginx:alpine running
$ docker stop web
web
$ docker ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}'
NAMES STATE STATUS
web exited Exited (0) Less than a second ago
$ docker start web
web
$ docker restart web
web
$ docker rename web web-old
$ docker rm web-old
Error response from daemon: cannot remove container "web-old": container is running: stop the container before removing or force remove
(exit status 1)
(That last command failed: a running container cannot be removed with plain rm.)
$ docker rm -f web-old
web-old
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
docker stop sends SIGTERM, waits ten seconds, then sends SIGKILL. A program that handles SIGTERM exits at once. A program that does not, stays until the ten seconds are up. The usual culprit is that your program runs as PID 1: the kernel gives PID 1 no default signal handlers, so a signal it does not handle explicitly is ignored. Here is a plain sleep, run as PID 1, then stopped:
$ docker run -d --name slow alpine sleep 300
0fb03a63f6922dc4a04a384b02b870474c53f54a22a56a7867e7cda3f3cfb091
$ s=$(date +%s); docker stop slow >/dev/null; echo "stopped after $(( $(date +%s) - s )) seconds"
stopped after 10 seconds
$ docker inspect -f 'exit code {{.State.ExitCode}}' slow
exit code 137
$ docker rm slow
slow
Exit code 137 is 128 + 9: it was killed with SIGKILL. The fix is a tiny init process as PID 1 that forwards signals and reaps children. Docker has one built in, --init:
$ docker run -d --init --name quick alpine sleep 300
5be05536d8b9a952a64e11054e3e9140e4d4ebd1eff6d545e1881cba5258015c
$ s=$(date +%s); docker stop quick >/dev/null; echo "stopped after $(( $(date +%s) - s )) seconds"
stopped after 0 seconds
$ docker inspect -f 'exit code {{.State.ExitCode}}' quick
exit code 143
$ docker rm quick
quick
(In compose, the same thing is init: true.) Related: use the exec form of CMD in a Dockerfile (CMD ["nginx", "-g", "daemon off;"], as a JSON array). The shell form (CMD nginx) starts /bin/sh -c as PID 1, and the shell does not pass signals on to your program. More in the build section.
Other signals and states:
$ docker run -d --name pz alpine sleep 300
62ab8aee3edc7e607661bd4e1754b39efe83c397350b547666e9ff6f807b1fdb
$ docker pause pz && docker ps --format '{{.Names}} {{.State}}'
pz
pz paused
$ docker unpause pz && docker kill pz
pz
pz
$ docker rm pz
pz
$ docker run -d --name stopme alpine sleep 300
e000c13fecbc32cfbc6382855b1d3de1e4765325edc7b7d9b79f05c53311cdd2
$ docker stop -t 2 stopme
stopme
$ docker container prune -f
Deleted Containers:
e000c13fecbc32cfbc6382855b1d3de1e4765325edc7b7d9b79f05c53311cdd2
Total reclaimed space: 4.096kB
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Inspect and debug
$ docker run -d --name web -p 8080:80 nginx:alpine
a0359e6817d583471a0e5c6a08d2ac368e0aa6d93a9249d714a6e357747b9532
$ curl -s localhost:8080 >/dev/null; curl -s localhost:8080/missing >/dev/null
$ docker logs web 2>&1 | grep -c 'GET /'
3
$ docker logs web 2>&1 | grep '" 404 ' | sed -E 's/^[0-9.]+ - - \[[^]]*\]/IP - - [DATE]/'
IP - - [DATE] "GET /missing HTTP/1.1" 404 153 "-" "curl/8.5.0" "-"
$ docker logs --since 1m web 2>&1 | grep -c 'GET /'
3
$ docker exec web nginx -v
nginx version: nginx/1.31.6
$ docker exec web ls /etc/nginx/conf.d
default.conf
$ docker exec web sh -c 'echo "I am $(id -un) with PID $$"'
I am root with PID 50
$ docker top web | head -3
UID PID PPID C STIME TTY TIME CMD
root 45754 45731 1 21:32 ? 00:00:00 nginx: master process nginx -g daemon off;
101 45813 45754 0 21:32 ? 00:00:00 nginx: worker process
$ docker stats --no-stream --format 'table {{.Name}}\t{{.PIDs}}\t{{.MemPerc}}'
NAME PIDS MEM %
web 9 0.02%
docker logs shows what the main process wrote to standard output and standard error (nginx's image links its logs to those). A program that writes only to a file inside the container shows nothing in docker logs. -f follows, --tail N limits, --since 10m and --until select a period, -t adds timestamps.
docker exec runs a second command in a running container. Add -it for a shell: docker exec -it web sh (the lab image has sh, not bash; many slim images have no shell at all, see the troubleshooting section).
inspect prints everything as JSON, and -f takes a Go template to pick out one value:
$ docker inspect -f '{{.State.Status}} since {{.State.StartedAt}}' web | cut -c1-14
running since
$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web
172.18.0.2
$ docker inspect -f '{{json .Config.Cmd}} {{json .Config.ExposedPorts}}' web
["nginx","-g","daemon off;"] {"80/tcp":{}}
$ docker inspect -f '{{json .HostConfig.PortBindings}}' web
{"80/tcp":[{"HostIp":"","HostPort":"8080"}]}
$ docker inspect web | head -4
[
{
"Id": "a0359e6817d583471a0e5c6a08d2ac368e0aa6d93a9249d714a6e357747b9532",
"Created": "2026-10-18T21:32:01.156417294Z",
$ docker inspect web | grep -c .
238
What did the container change in its own file system, and how do I get a file in or out:
$ docker exec web sh -c 'echo test > /tmp/note.txt'
$ docker diff web | grep -E ' /tmp| /run'
C /tmp
A /tmp/note.txt
C /run
A /run/nginx.pid
$ docker cp web:/etc/nginx/nginx.conf /tmp/nginx.conf && head -3 /tmp/nginx.conf
user nginx;
worker_processes auto;
$ echo 'copied in' > /tmp/in.txt && docker cp /tmp/in.txt web:/tmp/in.txt && docker exec web cat /tmp/in.txt
copied in
$ docker events --since 5m --until 0s --filter container=web --format '{{.Action}}' | head -4
create
start
kill
stop
In docker diff, A is added, C is changed and D is deleted. Everything the container writes goes into its own thin layer and is lost when the container is removed, unless it is in a volume. docker cp works on stopped containers too, which is how you get a file out of a container that has already crashed.
$ docker rm -f web
web
Images: tags, layers, saving and loading
An image is a stack of read-only layers plus some settings (the default command, environment, exposed ports). The name has up to four parts: registry/repository:tag, for example docker.io/library/nginx:1.27-alpine. Leave out the registry and Docker Hub is assumed; leave out the tag and :latest is assumed.
The tag is only a label that points at one image at a time, and it moves: nginx:latest today is not nginx:latest next month. Pin a version tag (nginx:1.27-alpine) for anything that must be reproducible, or a digest (nginx@sha256:...) for exactly that image.
$ docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}'
REPOSITORY TAG IMAGE ID SIZE
nginx alpine df221db836e1 94.4MB
alpine latest 294b683cb724 13MB
$ docker image inspect alpine -f '{{.Os}}/{{.Architecture}}, {{len .RootFS.Layers}} layer'
linux/amd64, 1 layer
$ docker image inspect nginx:alpine -f '{{.Os}}/{{.Architecture}}, {{len .RootFS.Layers}} layers'
linux/amd64, 8 layers
$ docker history --format '{{.CreatedBy}}' alpine
CMD ["/bin/sh"]
ADD alpine-minirootfs-3.24.2-x86_64.tar.gz /…
$ docker history --format '{{.Size}}\t{{.CreatedBy}}' nginx:alpine | head -4 | cut -c1-90
52.4MB RUN /bin/sh -c set -x && apkArch="$(cat …
0B ENV ACME_VERSION=0.4.1
0B ENV NJS_RELEASE=1
0B ENV NJS_VERSION=1.0.1
Giving an image a second name does not copy anything. It is a second tag on the same image ID (this is how you prepare a push to your own registry):
$ docker tag alpine:latest myapp:1.0
$ docker tag alpine:latest registry.example.com/team/myapp:1.0
$ docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}' | grep -E 'REPOSITORY|alpine|myapp'
REPOSITORY TAG IMAGE ID
nginx alpine df221db836e1
alpine latest 294b683cb724
myapp 1.0 294b683cb724
registry.example.com/team/myapp 1.0 294b683cb724
$ docker rmi registry.example.com/team/myapp:1.0 myapp:1.0
Untagged: registry.example.com/team/myapp:1.0
Untagged: myapp:1.0
rmi removed the two extra names, and the image itself stays because alpine:latest still points at it ("Untagged" in the output, not "Deleted").
Moving an image to a machine without a registry or without internet: save it to a tar file and load it on the other side.
$ docker save -o /tmp/alpine.tar alpine:latest && ls -l /tmp/alpine.tar | awk '{print $5, "bytes", $9}'
3958784 bytes /tmp/alpine.tar
$ docker rmi alpine:latest
Untagged: alpine:latest
Deleted: sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
$ docker load -i /tmp/alpine.tar
Loaded image: alpine:latest
$ docker run --rm alpine echo "still works"
still works
(docker save | gzip, and docker load, work through a pipe too: docker save app | ssh host docker load.)
The registry commands need a registry, so the lab did not run them. This is the full cycle:
$ docker pull nginx:1.27-alpine # download that tag (docker pull NAME alone means :latest)
$ docker search redis --limit 5 # search Docker Hub (official images have no slash in the name)
$ docker login registry.example.com # asks for a user and a token; stored in ~/.docker/config.json
$ docker push registry.example.com/team/myapp:1.0 # upload
$ docker logout registry.example.com
~/.docker/config.json can hold the login in plain text base64, which is not encryption. Use a credential helper (docker-credential-pass or docker-credential-secretservice) on a shared machine, and use a read-only access token, never your account password, in a script or CI job.
Docker Hub limits anonymous pulls per hour. A server that pulls many images should log in, or pull through a mirror (a registry:2 container with REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io).
Build your own image: Dockerfile and the layer cache
A Dockerfile is a list of instructions. Each RUN, COPY and ADD creates a layer, and docker build keeps a cache of every layer. The rule that follows from that: put what changes least at the top and what changes most at the bottom, so a small edit rebuilds only the last layers.
The lab only has alpine:latest, so the examples build on it. In a real file pin a version (FROM alpine:3.21). The program is a two-line script:
$ cat app.sh
#!/bin/sh
echo "app $VERSION as $(id -un) on $(hostname)"
$ cat Dockerfile
FROM alpine:latest
ARG VERSION=1.0
ENV VERSION=$VERSION
WORKDIR /app
COPY app.sh .
RUN chmod +x app.sh
USER nobody
CMD ["./app.sh"]
$ docker build --progress=plain -t myapp:1.0 . 2>&1 | grep -E '\[[0-9]/[0-9]\]|naming' | cut -c1-70
#4 [1/4] FROM docker.io/library/alpine:latest@sha256:294b683cb724975be
#6 [2/4] WORKDIR /app
#7 [3/4] COPY app.sh .
#8 [4/4] RUN chmod +x app.sh
#9 naming to docker.io/library/myapp:1.0 done
$ docker run --rm myapp:1.0
app 1.0 as nobody on 5df286462924
Build it again with nothing changed: every step is taken from the cache.
$ docker build --progress=plain -t myapp:1.0 . 2>&1 | grep -E 'CACHED' | cut -c1-70
#6 CACHED
#7 CACHED
#8 CACHED
Now change the script. Docker compares the files that COPY reads, sees the difference, and rebuilds that step and every step after it. The steps above it stay cached:
$ echo 'echo "second line"' >> app.sh
$ docker build --progress=plain -t myapp:1.1 . 2>&1 | grep -E '^#[0-9]+ (\[[0-9]/[0-9]\]|CACHED)' | cut -c1-70
#4 [1/4] FROM docker.io/library/alpine:latest@sha256:294b683cb724975be
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY app.sh .
#8 [4/4] RUN chmod +x app.sh
$ docker run --rm myapp:1.1
app 1.0 as nobody on fab14781e2e3
second line
A build argument changes the value for one build, and an environment variable at run time changes it for one container:
$ docker build -q --build-arg VERSION=2.0 -t myapp:2.0 . >/dev/null && docker run --rm myapp:2.0
app 2.0 as nobody on 1dd2b87d32cb
second line
$ docker run --rm -e VERSION=9 myapp:1.0
app 9 as nobody on 4b260bb4f4f5
$ docker run --rm --user root myapp:1.0 id -un
root
$ docker run --rm myapp:1.0 id
uid=65534(nobody) gid=65534(nobody) groups=65534(nobody)
The last one replaced the CMD with id. The first one overrode the USER. Everything given after the image name replaces CMD:
ENTRYPOINT ["cmd"] the fixed program. Arguments after the image name are added to it.
CMD ["arg"] the default command, or the default arguments for ENTRYPOINT. Replaced by anything after the image name.
$ cat Dockerfile.entry
FROM alpine:latest
ENTRYPOINT ["echo", "entry:"]
CMD ["default args"]
$ docker build -q -f Dockerfile.entry -t entrydemo . >/dev/null
$ docker run --rm entrydemo
entry: default args
$ docker run --rm entrydemo other args
entry: other args
$ docker run --rm --entrypoint sh entrydemo -c 'echo replaced the entrypoint'
replaced the entrypoint
EXEC FORM VERSUS SHELL FORM. CMD ["sleep", "300"] (a JSON array) runs sleep directly as PID 1. CMD sleep 300 (no brackets) runs /bin/sh -c "sleep 300", and the shell is PID 1. A shell does not forward signals, so docker stop waits ten seconds and kills the container. Compare:
$ cat Dockerfile.shellform
FROM alpine:latest
CMD sleep 300 && echo done
$ docker build -q -f Dockerfile.shellform -t shellform . >/dev/null
$ docker run -d --name sf shellform
b0635e3294146a215745011de18af534373651d32d65b700b6c71fda95f405e8
$ docker exec sf ps -o pid,args
PID COMMAND
1 /bin/sh -c sleep 300 && echo done
7 sleep 300
8 ps -o pid,args
$ docker rm -f sf
sf
(The && makes the shell stay as PID 1 here, and sleep is its child. Without it, some shells replace themselves with the last command, which hides the problem on some images and not on others, so use the exec form.)
THE BUILD CONTEXT. docker build . sends the folder to the daemon, and a COPY . /src copies all of it into the image, including things that must never be there. A .dockerignore file, with the same syntax as .gitignore, leaves them out. Here a folder has a big file and a .env file with a password:
$ cat Dockerfile.ctx
FROM alpine:latest
COPY . /src
CMD ["ls", "-A", "/src"]
$ head -c 20000000 /dev/zero > big.bin; echo 'DB_PASSWORD=hunter2' > .env
$ docker build -q -f Dockerfile.ctx -t ctx . >/dev/null
$ docker run --rm ctx
.dockerignore
Dockerfile
Dockerfile.ctx
Dockerfile.dang
Dockerfile.entry
Dockerfile.leak
Dockerfile.multi
Dockerfile.secret
Dockerfile.shellform
Dockerfile.single
Dockerfile.tmp
app.sh
compose.yaml
data.tgz
out
site
tok.txt
$ docker images ctx --format '{{.Size}}'
13MB
$ cat .dockerignore
big.bin
.git
*.log
.env
$ docker build -q -f Dockerfile.ctx -t ctx . >/dev/null
$ docker run --rm ctx
.dockerignore
Dockerfile
Dockerfile.ctx
Dockerfile.dang
Dockerfile.entry
Dockerfile.leak
Dockerfile.multi
Dockerfile.secret
Dockerfile.shellform
Dockerfile.single
Dockerfile.tmp
app.sh
compose.yaml
data.tgz
out
site
tok.txt
$ docker images ctx --format '{{.Size}}'
13MB
$ rm big.bin .env
The first image carries the 20 MB file and the password, and anyone who can pull the image can read both. Write the .dockerignore before the first COPY . and keep .git, .env, key files, logs and build output in it.
SECRETS DO NOT BELONG IN A LAYER. Anything given with ARG or ENV, or copied and then deleted in a later step, stays in the image history, and docker history shows it:
$ cat Dockerfile.leak
FROM alpine:latest
ARG TOKEN
ENV API_TOKEN=$TOKEN
$ docker build -q -f Dockerfile.leak --build-arg TOKEN=s3cret-token -t leaky . >/dev/null
$ docker history leaky --no-trunc --format '{{.CreatedBy}}' | grep -c 's3cret-token'
2
The fix is a BuildKit secret mount: the file is available for one RUN step and never written into a layer.
$ cat Dockerfile.secret
FROM alpine:latest
RUN --mount=type=secret,id=tok wc -c < /run/secrets/tok > /size.txt
CMD ["cat", "/size.txt"]
$ printf 's3cret-token' > tok.txt
$ docker build -q -f Dockerfile.secret --secret id=tok,src=tok.txt -t safe . >/dev/null
$ docker run --rm safe
12
$ docker history safe --no-trunc --format '{{.CreatedBy}}' | grep -c 's3cret-token' || true
0
(The image can use the secret while it builds, here only to count its bytes, and the token itself is in no layer. Runtime secrets are a different matter: pass them at run time, with a file, an environment variable from your secret store, or a compose secret.)
Other instructions you will meet: LABEL (metadata), EXPOSE (documentation only, it does not publish anything), VOLUME (marks a path that gets an anonymous volume), HEALTHCHECK (a command that decides healthy or not), STOPSIGNAL, SHELL. buildx build --platform linux/amd64,linux/arm64 builds for several CPU types (it needs a builder that supports it, and was not run in the lab).
Multi-stage builds: build with the tools, ship without them
A compiler, a package cache and test files are needed to build a program and not to run it. A multi-stage Dockerfile builds in one stage and copies only the result into a clean final stage. Real builds use a compiler image such as golang or node for the first stage. The lab has no registry, so the build stage here makes a large temporary file to stand for build tools, and the artifact is a small text file:
$ cat Dockerfile.single
FROM alpine:latest
RUN head -c 30000000 /dev/zero > /build-tools.tmp && echo "the program" > /app.txt
CMD ["cat", "/app.txt"]
$ cat Dockerfile.multi
FROM alpine:latest AS build
RUN head -c 30000000 /dev/zero > /build-tools.tmp && echo "the program" > /app.txt
FROM alpine:latest
COPY --from=build /app.txt /app.txt
CMD ["cat", "/app.txt"]
$ docker build -q -f Dockerfile.single -t single . >/dev/null
$ docker build -q -f Dockerfile.multi -t multi . >/dev/null
$ docker run --rm single
the program
$ docker run --rm multi
the program
$ docker images --format 'table {{.Repository}}\t{{.Size}}' | grep -E 'REPOSITORY|single|multi'
REPOSITORY SIZE
single 43MB
multi 12.9MB
Same output, and the multi-stage image is about 30 MB smaller because the tools stayed in the first stage. The first stage is thrown away (it is only a build cache). FROM ... AS build names a stage, COPY --from=build takes files from it, and docker build --target build stops at a stage, which is useful for a test or debug image.
The real-world shape of it (not run, needs the golang image from a registry):
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached until the dependencies change
COPY . .
RUN CGO_ENABLED=0 go build -o /app
FROM gcr.io/distroless/static # no shell, no package manager
COPY --from=build /app /app
USER 65532:65532
ENTRYPOINT ["/app"]
Small final images are faster to pull and have fewer packages to patch. Distroless and scratch images have no shell at all, so docker exec -it sh will not work on them: see the troubleshooting section for how to look inside.
Volumes, bind mounts and tmpfs
A container's own layer is deleted with the container. Data that must survive goes in one of three places:
named volume -v data:/data Docker creates and manages it. The right choice for a database.
bind mount -v /host/dir:/data A folder from the host. Good for configuration and for development.
tmpfs --tmpfs /scratch Memory only, gone when the container stops. For temporary or secret data.
$ docker volume create data
data
$ docker run --rm -v data:/data alpine sh -c 'echo saved > /data/note.txt'
$ docker run --rm -v data:/data alpine cat /data/note.txt
saved
$ docker volume ls
DRIVER VOLUME NAME
local data
$ docker volume inspect data -f '{{.Mountpoint}}'
/var/lib/docker/volumes/data/_data
The first container wrote the file and was deleted, and the second one still found it. On the host the data is under /var/lib/docker/volumes, readable by root only. A named volume that is empty gets the contents of the image's folder copied in on first use (a bind mount does not: it hides what the image had there).
BIND MOUNTS show a host folder inside the container. Files you change on the host show up at once, which is how a development setup works. Add :ro to make it read-only for the container:
$ mkdir -p site && echo '<h1>version one</h1>' > site/index.html
$ docker run -d --name web -p 8080:80 -v "$PWD/site":/usr/share/nginx/html:ro nginx:alpine
bda89e3f5c9d776f006b443e5cddc01ddfc2568de24596fd441b7c213b921dbc
$ curl -s localhost:8080
<h1>version one</h1>
$ echo '<h1>version two</h1>' > site/index.html && curl -s localhost:8080
<h1>version two</h1>
$ docker exec web sh -c 'echo x > /usr/share/nginx/html/new.html'
sh: can't create /usr/share/nginx/html/new.html: Read-only file system
(exit status 1)
$ docker rm -f web
web
Use the absolute path form, "$PWD/site" or the full path. A relative name such as -v site:/x would mean a named volume called site, which is a very common mistake.
THE OWNER OF FILES. A container's user ids are the host's user ids, with different names. The default container user is root (uid 0), so files it creates in a bind mount belong to root on the host:
$ mkdir out
mkdir: cannot create directory 'out': File exists
(exit status 1)
$ docker run --rm -v "$PWD/out":/out alpine sh -c 'echo made-by-container > /out/file'
$ ls -ln out
total 8
-rw-r--r-- 1 1000 1000 18 Oct 18 21:32 file
-rw-r--r-- 1 1000 1000 16 Oct 18 21:30 mine
$ echo more >> out/file
$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out":/out alpine sh -c 'echo made-as-me > /out/mine'
$ ls -ln out
total 8
-rw-r--r-- 1 1000 1000 23 Oct 18 21:32 file
-rw-r--r-- 1 1000 1000 11 Oct 18 21:32 mine
$ echo more >> out/mine && cat out/mine
made-as-me
more
The first file is owned by uid 0, so the ordinary user cannot edit it. Running with --user "$(id -u):$(id -g)" (or USER in the Dockerfile) makes the files yours. A named volume avoids the question, and compose has user: for it too.
BACK UP AND RESTORE A VOLUME with a throwaway container that mounts the volume and the current folder:
$ docker run --rm -v data:/data:ro -v "$PWD":/backup alpine tar czf /backup/data.tgz -C /data .
$ tar tzf data.tgz
./
./note.txt
$ docker volume create restored >/dev/null && docker run --rm -v restored:/data -v "$PWD":/backup alpine tar xzf /backup/data.tgz -C /data
$ docker run --rm -v restored:/data alpine cat /data/note.txt
saved
For a database do not copy the files of a running database: dump it (pg_dump, mysqldump) or stop the container first.
tmpfs is memory, with an optional size:
$ docker run --rm --tmpfs /scratch:size=1m alpine sh -c 'df -h /scratch | tail -1 | tr -s " " | cut -d" " -f1-3'
tmpfs 1.0M 0
$ docker run --rm --tmpfs /scratch:size=1m alpine dd if=/dev/zero of=/scratch/big bs=1M count=2
dd: error writing '/scratch/big': No space left on device
2+0 records in
1+0 records out
1048576 bytes (1.0MB) copied, 0.001685 seconds, 593.5MB/s
(exit status 1)
ANONYMOUS VOLUMES pile up. An image with VOLUME in its Dockerfile, or a -v /path with no name, creates a volume with a random name for every container, and docker rm does not remove it unless you add -v:
$ docker volume ls -q | wc -l
2
$ docker run -d --name anon -v /var/lib/thing alpine sleep 60 >/dev/null; docker volume ls -q | wc -l
3
$ docker rm -f anon >/dev/null; docker volume ls -q | wc -l
3
$ docker run -d --name anon2 -v /var/lib/thing alpine sleep 60 >/dev/null; docker rm -fv anon2 >/dev/null; docker volume ls -q | wc -l
3
Two volumes before, three with the first container running, and still three after it was removed: the anonymous volume was left behind. With rm -fv the second container's volume went with it, so the count stayed at three. docker volume prune removes unused anonymous volumes (and with -a also unused named ones, so be careful, see the cleanup section):
$ docker volume rm restored
restored
$ docker volume prune -f | head -1
Deleted Volumes:
$ docker volume ls
DRIVER VOLUME NAME
local data
Networks: how containers find each other
Docker creates a bridge network and gives each container an address on it, and the host forwards published ports to it. Three kinds matter in practice:
bridge (the default) containers can reach each other by IP address only.
user-defined bridge the same, plus DNS: a container can reach another by its name. Use this.
host the container uses the host's network directly. No isolation, no -p needed.
none only a loopback interface.
$ docker network ls --format 'table {{.Name}}\t{{.Driver}}\t{{.Scope}}'
NAME DRIVER SCOPE
bridge bridge local
host host local
none null local
On the DEFAULT bridge, names do not resolve:
$ docker run -d --name a1 alpine sleep 300 >/dev/null
$ docker run -d --name a2 alpine sleep 300 >/dev/null
$ docker exec a1 ping -c1 a2
ping: bad address 'a2'
(exit status 1)
$ docker exec a1 ping -c1 $(docker inspect a2 -f '{{.NetworkSettings.Networks.bridge.IPAddress}}') | head -2
PING 172.18.0.3 (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: seq=0 ttl=64 time=0.086 ms
(In Docker 29 the old shortcut {{.NetworkSettings.IPAddress}} is gone: it fails with "map has no entry for key". Name the network, as above, or use the range form from the inspect section.)
By IP it works, but the address changes whenever a container is recreated, so nobody should depend on it. On a USER-DEFINED network, the name works:
$ docker rm -f a1 a2 >/dev/null
$ docker network create appnet
ee90eaf3cd1b56d07e9d9ef33233f9451b740236c2a38d623989f6213c5ddf3f
$ docker run -d --name web --network appnet nginx:alpine >/dev/null
$ docker run -d --name client --network appnet alpine sleep 300 >/dev/null
$ docker exec client sh -c 'ping -c1 web | head -1'
PING web (172.19.0.2): 56 data bytes
$ docker exec client wget -qS -O /dev/null http://web 2>&1 | head -1
HTTP/1.1 200 OK
$ docker network inspect appnet -f '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}' | sort | sed '/^$/d'
client 172.19.0.3/16
web 172.19.0.2/16
The client reached the web server on its inside port 80 without any -p. Publishing (-p) is only for traffic that comes from OUTSIDE the host. Two containers on the same network can use any port on each other.
A container can be on several networks, and networks can be joined and left while it runs. That is how you give a front end a network to the database that the outside world cannot see:
$ docker network create backend >/dev/null
$ docker network connect backend client
$ docker inspect client -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
appnet backend
$ docker network disconnect backend client && docker network rm backend
backend
The other modes:
$ docker run --rm --network none alpine ip -o -4 addr | cut -d' ' -f2
lo
$ docker run --rm --network host alpine sh -c "ip -o -4 addr | cut -d' ' -f2 | sort -u | tr '\n' ' '"; echo
br-ee90eaf3cd1b docker0 eth0 lo
$ docker rm -f web client >/dev/null; docker network rm appnet
appnet
(With host networking the container sees the host's interfaces, so the names are the host's (docker0 is the default bridge). Linux only, and a published -p is ignored.)
TWO THINGS THAT BITE ON SERVERS (not run, they change the firewall and the daemon).
1. Published ports go around ufw and firewalld, because Docker inserts its own rules ahead of them. Bind to 127.0.0.1, or filter in the DOCKER-USER chain, which Docker leaves alone and evaluates first:
$ sudo iptables -I DOCKER-USER -i eth0 ! -s 192.168.1.0/24 -j DROP # only the LAN may reach published ports
2. The default bridge uses 172.17.0.0/16 and new networks take the next ranges. If your office or VPN uses one of those, the routes clash. Set the pools in /etc/docker/daemon.json and restart the daemon:
{ "default-address-pools": [ { "base": "10.200.0.0/16", "size": 24 } ] }
Docker Compose: several containers from one file
Compose describes a set of containers, networks and volumes in one YAML file and starts them together. On a current system the command is docker compose (a plugin; Ubuntu's package is docker-compose-v2). The old docker-compose with a hyphen is the retired Python version 1, and the file format is the same for both.
A web server with a health check, and a worker that must wait until the web server is healthy. The web folder is the site/ from the volumes section:
$ cat compose.yaml
name: demo
services:
web:
image: nginx:alpine
ports:
- "127.0.0.1:${WEB_PORT:-8080}:80"
volumes:
- ./site:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/"]
interval: 2s
timeout: 2s
retries: 5
restart: unless-stopped
worker:
image: alpine:latest
command: ["sh", "-c", "while true; do wget -q -O /dev/null http://web/ && echo ok; sleep 2; done"]
environment:
MODE: demo
volumes:
- state:/state
depends_on:
web:
condition: service_healthy
volumes:
state:
$ docker compose config --quiet && echo "the file is valid"
the file is valid
$ docker compose up -d
Network demo_default Creating
Network demo_default Created
Volume demo_state Creating
Volume demo_state Created
Container demo-web-1 Creating
Container demo-web-1 Created
Container demo-worker-1 Creating
Container demo-worker-1 Created
Container demo-web-1 Starting
Container demo-web-1 Started
Container demo-web-1 Waiting
Container demo-web-1 Healthy
Container demo-worker-1 Starting
Container demo-worker-1 Started
$ docker compose ps --format 'table {{.Service}}\t{{.State}}\t{{.Ports}}'
SERVICE STATE PORTS
web running 127.0.0.1:8080->80/tcp
worker running
$ docker inspect demo-web-1 -f 'health: {{.State.Health.Status}}'
health: healthy
$ docker compose logs worker | grep -c ok
2
$ docker compose exec -T web nginx -v
nginx version: nginx/1.31.6
$ curl -s localhost:8080
<h1>version two</h1>
Reading it from the top. name: sets the project name, which prefixes everything Compose creates (demo-web-1, the network demo_default, the volume demo_state). Without it the folder name is used. The two services share a network that Compose creates, so worker reached web as http://web/. ports: publishes only on localhost. The health check runs inside the container; depends_on with condition: service_healthy makes the worker wait for it (plain depends_on only waits for the container to START, not for the program to be ready). exec -T switches off the terminal, which a script needs; without a terminal Compose would otherwise complain.
The value ${WEB_PORT:-8080} is filled from the environment or from a .env file in the same folder, with 8080 as the default:
$ docker compose config | grep -A1 'published'
published: "8080"
protocol: tcp
$ WEB_PORT=9090 docker compose config | grep 'published'
published: "9090"
$ docker compose config --services
web
worker
$ docker compose config | head -6
name: demo
services:
web:
healthcheck:
test:
- CMD
docker compose config prints the file as Compose understands it, after filling in variables and merging override files. It is the first thing to run when a file does not behave as you expect.
Change one thing and run up -d again: only the service that changed is recreated, and the rest are left running:
$ sed -i 's/MODE: demo/MODE: changed/' compose.yaml
$ docker compose up -d
Container demo-web-1 Running
Container demo-worker-1 Recreate
Container demo-worker-1 Recreated
Container demo-web-1 Waiting
Container demo-web-1 Healthy
Container demo-worker-1 Starting
Container demo-worker-1 Started
$ docker compose up -d --scale worker=2
Container demo-web-1 Running
Container demo-worker-1 Running
Container demo-worker-2 Creating
Container demo-worker-2 Created
Container demo-web-1 Waiting
Container demo-web-1 Healthy
Container demo-worker-2 Starting
Container demo-worker-2 Started
$ docker compose ps --format 'table {{.Name}}\t{{.State}}'
NAME STATE
demo-web-1 running
demo-worker-1 running
demo-worker-2 running
Stopping and removing, and what happens to the data:
$ docker compose down
Container demo-worker-1 Stopping
Container demo-worker-2 Stopping
Container demo-worker-1 Stopped
Container demo-worker-1 Removing
Container demo-worker-2 Stopped
Container demo-worker-2 Removing
Container demo-worker-1 Removed
Container demo-worker-2 Removed
Container demo-web-1 Stopping
Container demo-web-1 Stopped
Container demo-web-1 Removing
Container demo-web-1 Removed
Network demo_default Removing
Network demo_default Removed
$ docker volume ls --format '{{.Name}}' | grep demo
demo_state
$ docker compose down -v 2>&1 | tail -1
Volume demo_state Removed
$ docker volume ls --format '{{.Name}}' | grep -c demo || true
0
down removes the containers and the network but keeps the named volumes. down -v removes them too, and with them the data. (The -v said "Volume demo_state Removed", and then nothing called demo is left.) docker compose stop and start keep the containers.
Things you will want in a real file, none of which the lab needed to run: env_file: (a file of variables), user: "1000:1000", init: true (a PID 1 that forwards signals), read_only: true, cap_drop: [ALL], mem_limit: and cpus:, profiles: (services that start only when asked for, docker compose --profile debug up), and compose.override.yaml (merged automatically over compose.yaml, the place for developer-only settings). docker compose pull fetches newer images for the tags in the file, and docker compose up -d --pull always forces it. Neither was run, since the lab has no registry.
Limits: memory, CPU and processes
Without limits one container can use all the memory and CPU of the host. Limits are enforced by the kernel (cgroups), so they hold whatever the program does.
$ docker run --rm --memory 64m --cpus 0.5 --pids-limit 50 alpine sh -c 'echo "memory.max=$(cat /sys/fs/cgroup/memory.max) cpu.max=$(cat /sys/fs/cgroup/cpu.max) pids.max=$(cat /sys/fs/cgroup/pids.max)"'
memory.max=67108864 cpu.max=50000 100000 pids.max=50
A container that goes over its memory limit is killed by the kernel (the OOM killer), and Docker records it. This one tries to hold 50 MB in a 20 MB limit:
$ docker run --name hog --memory 20m --memory-swap 20m alpine sh -c 'x=$(head -c 50000000 /dev/zero | tr "\0" a); echo "allocated ${#x} bytes"'
Killed
(exit status 137)
$ docker inspect hog -f 'exit={{.State.ExitCode}} oom_killed={{.State.OOMKilled}}'
exit=137 oom_killed=true
$ docker rm hog
hog
Exit code 137 and OOMKilled=true: the process was killed, and it printed nothing, because it never got the chance. If a container disappears with 137 and no error in its logs, check this first. --memory-swap equal to --memory means no swap, so the limit is firm.
$ docker run -d --name limited --memory 100m alpine sleep 300
cf187ada4f7d18ffbcfcff31064983f141c4579d20b143a6cc14b5bb3f03b76d
$ docker inspect limited -f 'memory limit: {{.HostConfig.Memory}} bytes'
memory limit: 104857600 bytes
$ docker update --memory 200m --memory-swap 200m limited
limited
$ docker inspect limited -f 'memory limit: {{.HostConfig.Memory}} bytes'
memory limit: 209715200 bytes
$ docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}'
NAME MEM USAGE / LIMIT MEM %
limited 768KiB / 200MiB 0.38%
$ docker rm -f limited
limited
--cpus 0.5 means half of one CPU, as a share of time (cpu.max above shows 50000 of every 100000 microseconds). --cpu-shares only sets a priority for when the CPU is busy. Memory shown inside the container by tools such as free and top is the HOST's memory, not the limit, because they read /proc/meminfo: read the cgroup files, as above, for the truth. A program that sizes itself from the memory it sees (Java before version 10, many others) can be fooled, so set its heap explicitly.
In compose the same limits are mem_limit, cpus and pids_limit on the service.
Logs, and log files that fill the disk
Docker keeps what a container prints to standard output and error. The default driver, json-file, writes it under /var/lib/docker/containers and NEVER rotates it. A chatty container, left alone, fills the disk.
$ docker info -f 'logging driver: {{.LoggingDriver}}'
logging driver: json-file
$ docker run -d --name quiet alpine sleep 300
a42ad4c19ee1ffcf9c985b1222c74692e8683399c91455cc26beec678c28b18b
$ docker inspect quiet -f '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}'
json-file map[]
$ docker run -d --name capped --log-opt max-size=10m --log-opt max-file=3 alpine sleep 300
a713eec11a3d956da943b3def8efb6f4887a374daf4c67d689ab721862aec4b6
$ docker inspect capped -f '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}'
json-file map[max-file:3 max-size:10m]
$ docker rm -f quiet capped
quiet
capped
The first container has no limit. The second keeps at most three files of 10 MB. The setting for the whole host goes in /etc/docker/daemon.json and applies to containers created AFTER the daemon restarts (existing ones keep their old settings and have to be recreated):
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
(Not run: it needs systemctl restart docker.) In compose, put it on the service:
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
What reads the logs: docker logs (works with json-file and journald), the journald driver (log-driver: journald, then journalctl CONTAINER_NAME=web), and drivers that ship them elsewhere (syslog, gelf, fluentd, loki through its plugin). With a driver that does not keep local files, docker logs may show nothing, on purpose.
Find out where the disk space went:
$ docker system df --format 'table {{.Type}}\t{{.TotalCount}}\t{{.Size}}'
TYPE TOTAL SIZE
Images 12 155.1MB
Containers 0 0B
Local Volumes 1 6B
Build Cache 14 34.21MB
Container logs are not counted under the container's own size, so for the log size look at the files themselves, as root: sudo du -sh /var/lib/docker/containers/*/*-json.log (not run in the lab, where the ubuntu user is not root).
Security basics
A container is a process with a restricted view of the system. It is not a virtual machine, and the kernel is shared, so the defaults are not a security boundary against a determined attacker. These settings reduce what a compromised container can do. All of them ran in the lab.
1. DO NOT RUN AS ROOT. The default user is root (uid 0) inside the container. Set USER in the Dockerfile or --user at run time:
$ docker run --rm alpine id -u
0
$ docker run --rm --user 1000:1000 alpine id
uid=1000 gid=1000 groups=1000
2. READ-ONLY ROOT FILE SYSTEM, with a tmpfs for the places that need writing:
$ docker run --rm --read-only alpine touch /x
touch: /x: Read-only file system
(exit status 1)
$ docker run --rm --read-only --tmpfs /tmp alpine sh -c 'touch /tmp/x && echo "wrote to /tmp"'
wrote to /tmp
3. DROP CAPABILITIES. Root inside a container still has a default set of Linux capabilities (chown, setuid, net_raw and others). Take them all away and add back only what is needed:
$ docker run --rm alpine sh -c 'grep CapEff /proc/self/status'
CapEff: 00000000a80425fb
$ docker run --rm --cap-drop ALL alpine sh -c 'grep CapEff /proc/self/status'
CapEff: 0000000000000000
$ docker run --rm --cap-drop ALL alpine chown 1:1 /etc/hostname
chown: /etc/hostname: Operation not permitted
(exit status 1)
$ docker run --rm --cap-drop ALL --cap-add CHOWN alpine chown 1:1 /etc/hostname && echo "allowed with CHOWN"
allowed with CHOWN
4. NO NEW PRIVILEGES stops setuid programs inside from gaining rights:
$ docker run --rm alpine grep NoNewPrivs /proc/self/status
NoNewPrivs: 0
$ docker run --rm --security-opt no-new-privileges alpine grep NoNewPrivs /proc/self/status
NoNewPrivs: 1
5. NEVER --privileged unless you know why. It gives the container nearly all the host's devices and capabilities:
$ docker run --rm alpine sh -c 'ls /dev | wc -l'
15
$ docker run --rm --privileged alpine sh -c 'ls /dev | wc -l'
239
6. NEVER MOUNT /var/run/docker.sock into a container (-v /var/run/docker.sock:/var/run/docker.sock) unless that container is allowed to be root on the host: anything that can talk to the daemon can start a container that mounts the whole host, as shown in the first sections. Many "monitoring" and "CI" examples do it. Prefer a socket proxy that allows only read calls, or rootless mode.
7. KEEP SECRETS OUT OF IMAGES AND OUT OF ENVIRONMENT VARIABLES WHERE YOU CAN. docker inspect shows every environment variable to anyone in the docker group. Use files (a bind mount with :ro, or Compose secrets, which appear under /run/secrets) and the build-time secret mount from the build section.
8. KEEP IMAGES SMALL AND CURRENT. Pin versions, rebuild regularly to pick up patched base images, and scan them (docker scout cves IMAGE, or trivy image IMAGE). Neither tool was run in the lab.
9. USE THE SETTINGS TOGETHER. This is the shape of a locked-down container, and it works for a lot of programs once their writable folders are listed:
$ docker run --rm --user 1000:1000 --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges alpine sh -c 'id -u; touch /tmp/ok && echo "tmp is writable"; touch /nope 2>&1 | head -1'
1000
tmp is writable
touch: /nope: Read-only file system
Rootless Docker (the daemon runs as a normal user, set up with dockerd-rootless-setuptool.sh install) and user-namespace remapping (userns-remap in daemon.json) go further, so that root in a container is not root on the host. Both change how volumes and ports behave and were not run.
Cleanup and disk space
Docker never deletes anything by itself. Stopped containers, old images, unused networks and volumes all stay. This is what is there now:
$ docker system df --format 'table {{.Type}}\t{{.TotalCount}}\t{{.Size}}'
TYPE TOTAL SIZE
Images 12 155.1MB
Containers 0 0B
Local Volumes 1 6B
Build Cache 14 34.21MB
$ docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | sort | head -20
alpine:latest 13MB
ctx:latest 13MB
entrydemo:latest 12.9MB
leaky:latest 12.9MB
multi:latest 12.9MB
myapp:1.0 13MB
myapp:1.1 13MB
myapp:2.0 13MB
nginx:alpine 94.4MB
safe:latest 12.9MB
shellform:latest 12.9MB
single:latest 43MB
Older versions of Docker left a nameless image (shown as <none>, "dangling") behind every time you rebuilt a tag. In this lab, Docker 29 with its containerd image store did not: building the same tag twice left no <none> image, so docker image prune had nothing to remove here, and you will still meet dangling images on older engines. What does collect is images nobody uses any more. Prune can be limited with a filter, here to images carrying a label, so that only the scratch image goes:
$ cat Dockerfile.tmp
FROM alpine:latest
LABEL purpose=scratch
RUN date +%N > /built-at.txt
$ docker build -q -f Dockerfile.tmp -t scratchimg . >/dev/null
$ docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'scratchimg|nginx'
scratchimg:latest 12.9MB
nginx:alpine 94.4MB
$ docker image prune -a -f --filter label=purpose=scratch
Deleted Images:
untagged: scratchimg:latest
deleted: sha256:2f3159edec21ddc61e1ca4ba3a7102f23528d9d2663713730330ba064759055c
deleted: sha256:73ebbce04941aea4700b5abfdfd6d0f8649dc27c242ed0125680f244fb6d1fc2
deleted: sha256:b928cffc8a82fc42688d35acca91f89713d497f0908ab2655de6063560040910
Total reclaimed space: 4.263kB
$ docker images --format '{{.Repository}}:{{.Tag}}' | grep -c scratchimg || true
0
The prune commands, from the most careful to the most dangerous:
docker container prune stopped containers
docker image prune dangling images (no name)
docker network prune networks nobody uses
docker volume prune anonymous volumes nobody uses (with -a: named ones too, which is data loss)
docker builder prune the build cache
docker system prune stopped containers, unused networks, dangling images and the build cache
docker system prune -a the same, and EVERY image that no container uses
$ docker create --name old alpine >/dev/null; docker network create unused >/dev/null
$ docker system prune -f
Deleted Containers:
b76a1843315420e7332c29b244bedde9e72facd80db5dd6e1892c3e2e33fc3fd
Deleted Networks:
unused
Deleted build cache objects:
yflu83brecmdludku5nvy2k2y
lhjogbzfbonnd1cbll082i36q
3fercbm44emke1hliafymcczk
uh4sij7readbx4bbprzsjo05o
Total reclaimed space: 118.9kB
$ docker system df --format 'table {{.Type}}\t{{.TotalCount}}\t{{.Active}}'
TYPE TOTAL ACTIVE
Images 12 0
Containers 0 0
Local Volumes 1 0
Build Cache 11 0
docker system prune -a --volumes (not run) would also have removed alpine and nginx:alpine, because no container used them at that moment, and every volume nobody uses. Read what a prune lists before you confirm it, never run it blindly on a server that holds a database volume, and use filters to be selective: docker image prune -a --filter "until=720h" removes unused images older than 30 days.
When the disk is full the usual places are /var/lib/docker/containers (logs, see the logging section), /var/lib/docker/overlay2 (images and writable layers), /var/lib/docker/volumes (data) and the build cache. docker system df -v lists the worst offenders by name.
Errors and troubleshooting
Start with three commands: docker ps -a (is it running, what exit status), docker logs NAME (what it said), and docker inspect NAME (how it was started).
THE CONTAINER STARTS AND EXITS AT ONCE. A container lives exactly as long as its main process (PID 1). If the process ends, the container is Exited, even when nothing is wrong:
$ docker run -d --name quit alpine
ad8852c44cbbe654b06200562bcfb9bb259d0ad906ad27a5f8e3bd6a6f061a91
$ docker ps -a --filter name=quit --format '{{.Names}} {{.Status}}'
quit Exited (0) 1 second ago
$ docker run -d --name fails alpine sh -c 'echo "boom: config missing" >&2; exit 1'
37d63641051d4aa47e6a36e1b4766ad0d6d8d79883efb99fbe0b57663279c51d
$ docker logs fails
boom: config missing
$ docker inspect fails -f 'exit code {{.State.ExitCode}}'
exit code 1
$ docker rm quit fails
quit
fails
alpine's default command is a shell, with nothing to read, so it exits at once with status 0. The second container printed an error and exited with 1, and docker logs still has the message after the container is gone from docker ps.
THE EXIT CODE TELLS YOU WHICH KIND OF FAILURE:
$ docker run --rm alpine sh -c 'exit 3'; echo "exit=$?"
exit=3
$ docker run --rm alpine nosuchcommand; echo "exit=$?"
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: "nosuchcommand": executable file not found in $PATH
Run 'docker run --help' for more information
exit=127
$ docker run --rm alpine /etc/passwd; echo "exit=$?"
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: "/etc/passwd": permission denied
Run 'docker run --help' for more information
exit=126
$ docker run -d --name kz alpine sleep 300 >/dev/null; docker kill kz >/dev/null; docker wait kz; docker rm kz >/dev/null
137
125 Docker itself failed (a bad option, a name in use, a port in use).
126 The command was found but cannot be run (not executable).
127 The command was not found (a wrong path, or the image has no such program).
137 Killed with SIGKILL (128 + 9): out of memory, or docker stop timed out. Check OOMKilled.
139 Segmentation fault. 143 is SIGTERM (a normal stop).
Any other number is the program's own exit status.
THE IMAGE DOES NOT EXIST, or you are not allowed to pull it (a typo, a private registry without docker login):
$ docker run --rm nosuchimage:1; echo "exit=$?"
Unable to find image 'nosuchimage:1' locally
docker: Error response from daemon: pull access denied for nosuchimage, repository does not exist or may require 'docker login'
Run 'docker run --help' for more information
exit=125
"failed to connect to the docker API" (older versions say "Cannot connect to the Docker daemon ... Is the docker daemon running?") means the daemon is down or the socket is not where the client looks (check systemctl status docker, and the DOCKER_HOST variable, which overrides it):
$ DOCKER_HOST=unix:///nonexistent.sock docker ps; echo "exit=$?"
failed to connect to the docker API at unix:///nonexistent.sock; check if the path is correct and if the daemon is running: dial unix /nonexistent.sock: connect: no such file or directory
exit=1
LOOK INSIDE A CONTAINER THAT HAS NO SHELL. A distroless or scratch image has no sh, so docker exec -it NAME sh fails. Start a helper container that shares the first one's process and network namespaces, with your own tools in it:
$ docker run -d --name app nginx:alpine >/dev/null
$ docker run --rm --pid container:app --network container:app alpine sh -c 'ps | grep "nginx: master"; wget -qO- localhost | head -2'
1 root 0:00 nginx: master process nginx -g daemon off;
38 root 0:00 sh -c ps | grep "nginx: master"; wget -qO- localhost | head -2
45 root 0:00 grep nginx: master
<!DOCTYPE html>
<html>
$ docker rm -f app
app
The helper sees the other container's processes and reaches its ports on localhost. (Recent Docker versions also have docker debug NAME, which does this for you; it needs a Docker subscription and was not run.) To look at its files, docker cp them out (it works without a shell) or docker export NAME | tar -t.
CHANGES TO A FILE ARE NOT SEEN, or old code keeps running. Check, in this order: is the file inside the image (COPY) and not rebuilt (docker build --no-cache, or docker compose build --no-cache)? Is the container recreated (docker compose up -d does it only if the file or image changed, and a bind-mounted config is read by the program only at start, so restart it)? Is another container still holding the port?
A CONTAINER CANNOT REACH THE INTERNET, or a name. Check DNS from inside (docker run --rm alpine nslookup example.com) and the daemon's dns setting. On a machine with a VPN or a corporate resolver, containers often need "dns": ["10.0.0.2"] in /etc/docker/daemon.json. Check the host's forwarding: sysctl net.ipv4.ip_forward must be 1, and a firewall that sets the FORWARD policy to DROP breaks container traffic.
Two more you will meet: "no space left on device" (see the cleanup section), and file permission errors on a bind mount (a user id mismatch, see the volumes section).
The daemon and the host
On Ubuntu the daemon is a systemd service. None of these were run in the lab, which has no systemd (its daemon was started by hand):
$ systemctl status docker --no-pager
$ sudo systemctl enable --now docker
$ sudo journalctl -u docker --since "1 hour ago" # the daemon's own log
$ docker info # versions, storage driver, root dir, cgroup driver, warnings
The daemon reads /etc/docker/daemon.json. It is valid JSON (a comma in the wrong place stops the daemon from starting, and journalctl -u docker says where), and a restart applies it. The settings that matter on servers:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"live-restore": true,
"default-address-pools": [ { "base": "10.200.0.0/16", "size": 24 } ],
"dns": ["10.0.0.2"],
"data-root": "/srv/docker"
}
live-restore keeps containers running while the daemon restarts (for an upgrade), and data-root moves /var/lib/docker to a bigger disk. The current value of the first one in the lab:
$ docker info -f 'live restore enabled: {{.LiveRestoreEnabled}}'
live restore enabled: false
$ docker info -f 'root dir: {{.DockerRootDir}}'
root dir: /var/lib/docker
What to back up: the compose files and the Dockerfiles (in Git), the data in named volumes (the tar method in the volumes section, or a database dump), and your registry credentials. Images and containers can be rebuilt from those, so a backup of all of /var/lib/docker is rarely worth it and is unsafe while the daemon runs.
Upgrading: with the Ubuntu packages, sudo apt update && sudo apt upgrade docker.io. With live-restore enabled the containers keep running; without it they restart with the daemon, and those with a restart policy come back by themselves. Read the release notes first for anything that removes an option (Docker 29 removed the old .NetworkSettings.IPAddress field, for example).
Practical one-liners
A throwaway shell in an image, with the current folder mounted and the files owned by you:
$ docker run --rm -v "$PWD":/work -w /work --user "$(id -u):$(id -g)" alpine sh -c 'echo made in a container > from-container.txt'
$ ls -ln from-container.txt | awk '{print $3, $4, $9}'; rm from-container.txt
1000 1000 from-container.txt
A quick web server for a folder (read-only), then gone:
$ docker run -d --rm --name quick -p 127.0.0.1:8088:80 -v "$PWD/site":/usr/share/nginx/html:ro nginx:alpine >/dev/null
$ curl -s localhost:8088; docker stop quick >/dev/null
<h1>version two</h1>
A table of what is running, and short formats for scripts:
$ docker run -d --name a1 alpine sleep 300 >/dev/null; docker run -d --name a2 alpine sleep 300 >/dev/null
$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.State}}'
NAMES IMAGE STATE
a2 alpine running
a1 alpine running
$ docker ps -q | wc -l
2
$ docker ps --filter name=a1 --format '{{.Names}}'
a1
$ docker inspect -f '{{.Name}}: {{.State.Status}}' $(docker ps -q) | sort
/a1: running
/a2: running
$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' a1
172.18.0.2
Run something in every container, and stop or remove sets of them:
$ for c in $(docker ps -q); do docker exec $c hostname; done | wc -l
2
$ docker stop $(docker ps -q) >/dev/null && docker ps -q | wc -l
0
$ docker rm $(docker ps -aq) >/dev/null && docker ps -aq | wc -l
0
Get files out of a container that has already exited, which docker cp does, and see the exact command a container was started with:
$ docker run --name gone alpine sh -c 'echo result > /out.txt'
$ docker cp gone:/out.txt - | tar -xO
result
$ docker inspect gone -f '{{json .Config.Cmd}} user={{.Config.User}} image={{.Config.Image}}'
["sh","-c","echo result > /out.txt"] user= image=alpine
$ docker rm gone
gone
Wait for a container and use its exit status in a script:
$ docker run -d --name job alpine sh -c 'sleep 1; exit 7' >/dev/null
$ docker wait job
7
$ docker rm job
job
Cheat sheet
RUN docker run -d --name web -p 8080:80 nginx:alpine background, named, published
docker run --rm -it alpine sh a throwaway shell
docker run --rm -v "$PWD":/work -w /work IMAGE CMD work on the current folder
LIST docker ps / docker ps -a / docker ps -q
LIFECYCLE docker stop|start|restart|kill|rm [-f] NAME
DEBUG docker logs -f --tail 100 NAME docker exec -it NAME sh
docker inspect -f '{{...}}' NAME docker top NAME docker stats --no-stream
docker cp NAME:/path . docker diff NAME
IMAGES docker images docker pull IMAGE docker tag A B docker rmi IMAGE
docker save -o f.tar IMAGE docker load -i f.tar
BUILD docker build -t name:tag . docker build --no-cache -f Dockerfile.x .
VOLUMES docker volume create|ls|inspect|rm -v name:/path (named) -v "$PWD/dir":/path:ro (bind)
NETWORKS docker network create|ls|inspect|connect|disconnect|rm --network NAME
COMPOSE docker compose up -d down [-v] ps logs -f SERVICE exec -T SERVICE CMD config
LIMITS --memory 512m --cpus 1.5 --pids-limit 100 --restart unless-stopped
SAFE --user 1000:1000 --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges
CLEAN docker system df docker system prune docker image prune docker volume prune
Common problems
permission denied while trying to connect to the Docker daemon socket
You are not in the docker group (and the group is root, see the first sections), or you have not logged in again since being added. Use sudo, or newgrp docker.
failed to connect to the docker API (older versions: Cannot connect to the Docker daemon)
The daemon is not running, or DOCKER_HOST points somewhere else. systemctl status docker.
Conflict. The container name "/web" is already in use
A container with that name exists, even a stopped one. docker rm web, or use docker compose up, which recreates it.
Bind for 0.0.0.0:8080 failed: port is already allocated
Another container or a program on the host uses the port. docker ps, and ss -ltnp | grep 8080. The failed container stays in the Created state: remove it.
pull access denied, repository does not exist or may require docker login
A typo in the image name, a private image, or no login. docker login, and check the exact name and tag.
exec: "bash": executable file not found in $PATH
The image has no bash (Alpine and slim images). Use sh, or the helper container method in the troubleshooting section.
The container exits straight away with status 0
Its main process ended. A container needs a long-running foreground process: nginx -g "daemon off;", not a program that backgrounds itself.
Exited (137)
Killed: out of memory (docker inspect NAME -f '{{.State.OOMKilled}}') or stop timed out because PID 1 ignores SIGTERM (use init: true / --init and the exec form of CMD).
Files created in a bind mount belong to root
The container runs as root. Use --user "$(id -u):$(id -g)" or USER in the Dockerfile.
A -v site:/path mount is empty
A name without a slash is a NAMED volume, not a folder. Use "$PWD/site" or ./site in compose.
It works, and a published port is open to the whole network although ufw says closed
Docker's iptables rules come before ufw. Publish on 127.0.0.1, or filter in DOCKER-USER.
My changes to the image are not used
The tag points at the old image, or the build cache is stale. docker build --no-cache, and make sure the container was recreated from the new image.
no space left on device
docker system df, logs without rotation, old images and the build cache. See the cleanup and logging sections.
docker: unknown command: docker compose
The compose plugin is not installed. sudo apt install docker-compose-v2 (Ubuntu), or docker-compose-plugin (Docker's repository).
map has no entry for key "IPAddress"
Docker 29 removed that shortcut in docker inspect. Use {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}.
Tips
- Pin image versions (nginx:1.27-alpine, not nginx or nginx:latest) and rebuild on a schedule, so you get both repeatable builds and security fixes.
- One process per container, and let the container die when the process does. Run several containers instead of several programs.
- Containers are disposable. Keep nothing you need in the container's own layer: put data in a named volume, and the configuration in a bind-mounted file or an environment variable.
- Write the exec form: CMD ["prog", "arg"], and use --init (init: true in compose) so docker stop is fast and zombies are reaped.
- Order the Dockerfile from what changes least to what changes most, copy the dependency list and install first, the source last, and keep a .dockerignore.
- Use multi-stage builds and a small base image. Run as a non-root user, read-only, with capabilities dropped.
- Publish ports on 127.0.0.1 unless the whole network must reach them, and put a reverse proxy in front.
- Put a log size limit on everything (daemon.json), and look at docker system df now and then.
- Use a user-defined network or Compose, so containers reach each other by name, and do not rely on IP addresses.
- Use docker compose config before up, and keep the compose file in Git.
- The docker group is root. Give it out as carefully as sudo.
- docker run --help and docker COMMAND --help are accurate for the version you have, and docker system events shows what the daemon is doing.