Skip to main content
Docker · lesson 2 of 9
Docker beginner Lesson 2 of 9

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

  1. Change the hello-py Dockerfile so that arguments given to docker run are passed to the script instead of replacing it. Tag it hello-py:2.0 and run it with a 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']
  1. Build greeter:3 with the build argument GREETING=hola, and confirm from the image metadata (without running it) that APP_GREETING is baked in. Then pass '$APP_GREETING' as the run argument — does echo expand 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.

  1. Extend the .dockerignore from the last-but-one example so that app.txt is 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
  1. 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.

Frequently Asked Questions

What is the difference between CMD and ENTRYPOINT?
ENTRYPOINT is the executable a container always runs; CMD supplies default arguments. Arguments given after the image name in docker run replace CMD but are appended to ENTRYPOINT. With only CMD set, the run arguments replace the whole command.
Why does my Docker build re-run steps I did not change?
Docker reuses a cached layer only if the instruction and everything before it are unchanged. Once one step changes, such as a COPY of edited source, every step after it rebuilds. Put slow steps that do not depend on your source, like dependency installs, before the COPY of your code.
What is the difference between ARG and ENV in a Dockerfile?
ARG is a build-time variable you set with docker build --build-arg; it does not exist in the running container. ENV sets an environment variable that is stored in the image and visible to every container started from it. Copy an ARG into an ENV if the value is needed at runtime.
What does .dockerignore do?
.dockerignore lists files the build context excludes, so COPY . . cannot pick them up. Use it to keep secrets such as .env files, local dependency folders and version-control data out of the image, which also makes builds faster and images smaller.
Should I use the latest tag for base images?
Not in anything you want to reproduce. latest moves whenever the publisher pushes a new release, so the same Dockerfile can produce a different image next week. Pin a specific tag such as python:3.12-alpine, or a digest for full reproducibility.