Your first Dockerfile
A Dockerfile is a plain-text recipe. Each line is an instruction
that adds a layer to the resulting image.
Set up a working directory:
mkdir -p ~/build && cd ~/build
Create index.html:
<!doctype html>
<html>
<head><title>Docker Nginx</title></head>
<body><h2>Hello from the Nginx container</h2></body>
</html>
Now Dockerfile:
# Build a custom nginx with our welcome page.
FROM nginx:latest
COPY ./index.html /usr/share/nginx/html/index.html
Just two instructions:
FROM picks the base image. Every Dockerfile starts with FROM.
COPY adds files from the build context (the directory you
pass to docker build) into the image.
Build it:
docker build -t nginxwebserver .
docker images | grep nginxwebserver
The -t (tag) names the resulting image. . is the build context.
Run it and curl yourself:
docker run -d --name web -p 8080:80 nginxwebserver
curl -s http://localhost:8080 | grep "Hello from"
Click Verify step once your custom page is being served.
Hint
`FROM nginx:latest` + `COPY ./index.html /usr/share/nginx/html/index.html`.
EXPOSE, CMD, and the build cache
Two more Dockerfile instructions you'll see everywhere:
EXPOSE 80 โ documents which port the image expects. It does
NOT publish the port (that's -p at runtime). Tooling like
docker ps shows EXPOSE'd ports as hints.
CMD ["nginx", "-g", "daemon off;"] โ the default command when
the container starts. Override at docker run time by trailing
args. The "exec form" (JSON array) is preferred โ it skips a shell
layer.
Rewrite the Dockerfile, this time on the smaller alpine base:
FROM nginx:1.11-alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Rebuild:
docker build -t nginxwebserver:alpine .
docker images | grep nginxwebserver
Layer cache demo. Touch index.html to force a content change,
then rebuild:
echo "<!-- cache buster $(date) -->" >> index.html
docker build -t nginxwebserver:alpine .
The output shows CACHED for the FROM layer (no work) but a new
layer for the COPY (because the file content changed). That's how
Docker speeds up incremental builds โ only layers whose content
changed (or whose ancestor changed) re-run.
Run the new tag on a different port so it doesn't collide:
docker run -d --name web-alpine -p 8081:80 nginxwebserver:alpine
curl -s http://localhost:8081 | grep "Hello from"
Click Verify step.
Hint
EXPOSE documents intent; CMD sets the default command. Layers cache by content.
Installing packages with RUN
RUN executes a shell command at build time and bakes the result
into a layer. It's how you install software on top of a base image.
Create a fresh directory and a Dockerfile for Apache on Ubuntu:
mkdir -p ~/apache && cd ~/apache
Dockerfile:
FROM ubuntu:22.04
# Combine apt commands into ONE RUN so the cache + apt-list cleanup
# fit in a single layer. apt-get is the script-friendly variant.
RUN apt-get update \
&& apt-get install -y --no-install-recommends apache2 apache2-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
EXPOSE 80
CMD ["apache2ctl", "-D", "FOREGROUND"]
Two non-obvious rules:
- Chain
apt commands with && โ each RUN is its own layer.
Splitting apt-get update and apt-get install into separate
RUNs would let the update layer get cached but the install layer
reference stale package indexes.
- Clean up in the same RUN โ
rm -rf /var/lib/apt/lists/*
removes the apt index files. If you do it in a separate RUN
the previous layer still holds the bytes, so image size doesn't
drop.
Build + run:
docker build -t apacheweb .
docker run -d --name apache -p 8082:80 apacheweb
curl -sI http://localhost:8082 | head -1
You should see HTTP/1.1 200 OK from Apache. Click Verify step.
Hint
FROM ubuntu, then `RUN apt-get update && apt-get install -y apache2`.
Multi-stage builds
Multi-stage builds keep build tooling out of your final image. Use
one stage to compile, another to ship โ COPY --from= pulls
artifacts between them.
A worked example: compile a tiny C program and ship just the binary.
mkdir -p ~/multistage && cd ~/multistage
cat > hello.c <<'EOF'
#include <stdio.h>
int main(void) { puts("hello from a tiny image"); return 0; }
EOF
Dockerfile:
# ---- builder stage ----
FROM gcc:13 AS builder
WORKDIR /src
COPY hello.c .
RUN gcc -O2 -static -o hello hello.c
# ---- final stage ----
FROM scratch
COPY --from=builder /src/hello /hello
CMD ["/hello"]
scratch is the empty base โ no shell, no libc, nothing. Because we
statically linked the binary, it stands on its own.
Build + check size:
docker build -t tinyhello .
docker images tinyhello
docker run --rm tinyhello
Compare the final image size to the gcc:13 builder โ that's the
size delta multi-stage buys you:
docker images gcc tinyhello
You should see tinyhello at well under 1MB, while gcc:13 is
hundreds of megabytes. Same source, very different shipping cost.
Click Verify step once docker run --rm tinyhello prints the
greeting.
Hint
Two `FROM` lines; copy artifacts from the first into the second with `COPY --from=`.
Push to a local registry
Pushing to Docker Hub needs an account and a docker login.
Pushing to any other registry works the same way:
docker tag <image> <registry>/<repo>:<tag>
docker push <registry>/<repo>:<tag>
Examples you'll meet in the wild:
docker.io/<user>/<image>:tag # Docker Hub
gcr.io/<project>/<image>:tag # Google Container Registry (legacy)
<region>-docker.pkg.dev/<project>/<repo>/<image>:tag # Google Artifact Registry
<account>.dkr.ecr.<region>.amazonaws.com/<repo>:tag # AWS ECR
For this lab we'll run a registry inside the sandbox โ no
credentials, no external dependency. It's the same registry binary
GitHub Container Registry runs under the hood.
docker run -d --name registry --restart=always -p 5000:5000 registry:2
Tag your nginx image for the new registry and push:
docker tag nginxwebserver localhost:5000/nginxwebserver:v1
docker push localhost:5000/nginxwebserver:v1
Confirm by pulling it back through the registry instead of from the
local cache:
docker rmi localhost:5000/nginxwebserver:v1 nginxwebserver
docker pull localhost:5000/nginxwebserver:v1
docker images | grep nginxwebserver
The registry's catalog API answers GET /v2/_catalog:
curl -s http://localhost:5000/v2/_catalog | jq
You should see {"repositories":["nginxwebserver"]}. Click
Verify step.
Hint
Run `registry:2` on localhost:5000, tag your image to `localhost:5000/...`, push, then pull back.