Docker · lesson 2 of 9
Intermediate
3Also part of Docker
5- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + Dockerizing Spring Boot Applications from Java
- + Bash Process Management from Bash
- + CIS Docker Benchmark Checks with docker-bench-security from Cloud Security
- + Signing Container Images with Cosign: Verify the Digest, Not the Tag from DevSecOps (AppSec in the pipeline)
Docker Images and Dockerfiles
Write a Dockerfile, build and run your own image, order layers for the build cache, use ENTRYPOINT, CMD, ARG and .dockerignore, and push to a registry.
By SkillByExample · Updated
An image is built from a Dockerfile: a list of instructions, each producing one layer.
The examples use a scratch directory with a tiny Python script and the python:3.12-alpine base
image.
Build and run your first image
# app.py
import platform
import sys
print(f"hello from Python {platform.python_version()}")
print("args:", sys.argv[1:])
FROM python:3.12-alpine
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
$ docker build -q -t hello-py:1.0 .
sha256:6a522ec3c071a3413dc619122feff3c445b7dac796e49eb06eb930e42279ffa8
$ docker run --rm hello-py:1.0
hello from Python 3.12.15
args: []
$ docker run --rm hello-py:1.0 python -c "print('CMD replaced')"
CMD replaced
FROM picks the base image, WORKDIR creates and enters /app, COPY copies from the build
context (the . at the end of docker build) into the image, and CMD is the default command.
-q prints only the new image ID. Anything after the image name in docker run replaces
CMD entirely, which is why the third command ran a different program.
Layers and history
$ docker history --format '{{.CreatedBy}} | {{.Size}}' hello-py:1.0 | head -4
CMD ["python" "app.py"] | 0B
COPY app.py . # buildkit | 12.3kB
WORKDIR /app | 8.19kB
CMD ["python3"] | 0B
$ docker image ls hello-py
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
hello-py:1.0 6a522ec3c071 83MB 20.6MB
docker history lists layers newest first. Our three instructions sit on top of the base image’s
own layers (the CMD ["python3"] line belongs to python:3.12-alpine). Metadata-only
instructions like CMD add 0 bytes; only filesystem changes have size. Almost all of the 83 MB is
the base image, which is shared on disk by every image built FROM it.
Order instructions for the build cache
Docker reuses a cached layer only if that instruction and every one before it are unchanged. Here the slow step (building a virtualenv) comes before the source copy:
FROM python:3.12-alpine
WORKDIR /app
# Slow step that does not depend on your source: build a virtualenv.
RUN python -m venv /venv
COPY app.py .
CMD ["/venv/bin/python", "app.py"]
Edit the source, then rebuild this file and a Dockerfile.bad that swaps the RUN and COPY
lines:
$ echo 'print("edited again")' >> app.py
$ docker build --progress=plain -t cache-demo . 2>&1 | grep -E '\[[0-9]/[0-9]\] [A-Z]|CACHED|DONE [1-9]'
#5 [1/4] FROM docker.io/library/python:3.12-alpine@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] RUN python -m venv /venv
#7 CACHED
#8 [4/4] COPY app.py .
$ docker build --progress=plain -f Dockerfile.bad -t cache-bad . 2>&1 | grep -E '\[[0-9]/[0-9]\] [A-Z]|CACHED|DONE [1-9]'
#4 [1/4] FROM docker.io/library/python:3.12-alpine@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY app.py .
#8 [4/4] RUN python -m venv /venv
#8 DONE 4.3s
#9 DONE 1.0s
In the good order the RUN step is CACHED and only the COPY reruns. In the bad order, the
changed app.py invalidates the COPY layer and everything after it, so the 4.3-second
virtualenv build runs again on every code edit. Real projects apply the same rule with
dependencies: COPY requirements.txt (or package.json), install, then COPY the source.
ENTRYPOINT, CMD and ARG
FROM alpine:3.20
ARG GREETING=hello
ENV APP_GREETING=$GREETING
ENTRYPOINT ["echo", "greeting:"]
CMD ["world"]
$ docker build -q -t greeter:1 .
sha256:1fdb4752bdb79408a1fdde3c3598f947050167c0db20b734dd0638f49da4306f
$ docker run --rm greeter:1
greeting: world
$ docker run --rm greeter:1 Docker
greeting: Docker
$ docker build -q --build-arg GREETING=bonjour -t greeter:2 .
sha256:92025107a555868d72a141d9b34ca381058c7d77afe374f3ad396f8fffe26446
$ docker run --rm --entrypoint printenv greeter:2 APP_GREETING
bonjour
$ docker run --rm --entrypoint printenv greeter:2 GREETING || echo "exit code $?"
exit code 1
With both set, the container runs ENTRYPOINT + CMD. A run argument (Docker) replaces only
CMD, so echo greeting: always runs; --entrypoint overrides the executable itself. ARG is a
build-time variable: --build-arg changed it, and copying it into ENV baked bonjour into
the image. The ARG itself is gone at runtime — printenv GREETING finds nothing and exits 1.
Keep files out with .dockerignore
COPY . . copies the whole build context, including files you never meant to ship:
FROM alpine:3.20
WORKDIR /src
COPY . .
CMD ["ls", "-A"]
$ docker build -q -t ctx:1 .
sha256:25465f5a4397d5e3153ae0ef4b0fc5cdfdb6d2106a9ca3e3f9844112b9acb618
$ docker run --rm ctx:1
.env
Dockerfile
app.txt
node_modules
$ printf '.env\nnode_modules\n' > .dockerignore
$ docker build -q -t ctx:2 .
sha256:4bbae17d3c9ffc61e6a7b3685bdf9d0d8b92d53f9dc4d0e8b7060ff548073b52
$ docker run --rm ctx:2
.dockerignore
Dockerfile
app.txt
The first image contains the .env file (here holding a password) and a local node_modules.
Anyone who pulls that image can read the secret. Patterns in .dockerignore are removed from the
context before it is sent to the builder, so no instruction can copy them.
Tag and push to a registry
A registry stores images by host/name:tag. Docker Hub is the default host; here a local
registry:2.8.3 container stands in so nothing needs an account.
docker run -d --name registry -p 5000:5000 registry:2.8.3
docker tag hello-py:1.0 localhost:5000/hello-py:1.0
docker push localhost:5000/hello-py:1.0
docker rmi localhost:5000/hello-py:1.0 hello-py:1.0
docker run --rm localhost:5000/hello-py:1.0
$ docker run -d --name registry -p 5000:5000 registry:2.8.3
e9bbc18acc924cdc5791d95c9c77feacc58dac1dd971da24591e0d5a63fda8fa
$ docker tag hello-py:1.0 localhost:5000/hello-py:1.0
$ docker push localhost:5000/hello-py:1.0
The push refers to repository [localhost:5000/hello-py]
9b3d8eee4f22: Pushed
...
283ac05c32e6: Pushed
1.0: digest: sha256:b41556f15702ba401c97aa8b9f1881657656c5505e37da11c3f2c884067bb5ff size: 856
$ docker rmi localhost:5000/hello-py:1.0 hello-py:1.0
Untagged: localhost:5000/hello-py:1.0
Untagged: hello-py:1.0
Deleted: sha256:b41556f15702ba401c97aa8b9f1881657656c5505e37da11c3f2c884067bb5ff
$ docker run --rm localhost:5000/hello-py:1.0
Unable to find image 'localhost:5000/hello-py:1.0' locally
1.0: Pulling from hello-py
19f531347747: Pulling fs layer
44136fa355b3: Already exists
...
Status: Downloaded newer image for localhost:5000/hello-py:1.0
hello from Python 3.12.15
args: []
docker tag adds a second name to the same image; the registry host is part of the name, which
tells push where to send it. After deleting the local copy, run pulled it back — and layers
still on disk from the base image show Already exists. Pushing to Docker Hub is the same flow
with docker login and a yourname/hello-py:1.0 tag.
Practice
- Change the
hello-pyDockerfile so that arguments given todocker runare passed to the script instead of replacing it. Tag ithello-py:2.0and run it witha b.
Solution
Replace CMD with ENTRYPOINT:
FROM python:3.12-alpine
WORKDIR /app
COPY app.py .
ENTRYPOINT ["python", "app.py"]
$ docker build -q -f Dockerfile.v2 -t hello-py:2.0 .
sha256:b6451ae151755f0ef4619fb635f1e9f4fd3e18bdab58587683a9c0653f98ef77
$ docker run --rm hello-py:2.0 a b
hello from Python 3.12.15
args: ['a', 'b']
- Build
greeter:3with the build argumentGREETING=hola, and confirm from the image metadata (without running it) thatAPP_GREETINGis baked in. Then pass'$APP_GREETING'as the run argument — doesechoexpand it?
Solution
$ docker build -q --build-arg GREETING=hola -t greeter:3 .
sha256:ac46bc9bc7383b58de374379a4cc87986686ea1a3675849ce262f0344687efc4
$ docker inspect --format '{{json .Config.Env}}' greeter:3
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","APP_GREETING=hola"]
$ docker run --rm greeter:3 '$APP_GREETING'
greeting: $APP_GREETING
No. The exec form (["echo", ...]) runs echo directly, with no shell to expand variables. Use
ENTRYPOINT ["sh", "-c", "echo greeting: $APP_GREETING"] if you need expansion.
- Extend the
.dockerignorefrom the last-but-one example so thatapp.txtis excluded too, using a wildcard pattern.
Solution
$ echo '*.txt' >> .dockerignore
$ docker build -q -t ctx:3 .
sha256:f93b5377ff353c8ef03d7abc626f75a8a6412ec829ee2d5a3bdc18b9ccb58a21
$ docker run --rm ctx:3
.dockerignore
Dockerfile
- Clean up: remove the registry container and every image you built in this lesson.
Solution
$ docker rm -f registry
registry
$ docker rmi hello-py:2.0 localhost:5000/hello-py:1.0 cache-demo cache-bad greeter:1 greeter:2 greeter:3 ctx:1 ctx:2 ctx:3 | grep -c Untagged
10
docker rm -f stops and removes a running container in one step. docker rmi takes several
image names at once (piping to grep -c just counts the ten Untagged lines); an image still used by a container cannot be removed until that container is
gone.