Cloud AI Campus
  • Career paths
  • Learning paths
  • Hands-on Labs
Log in Sign up

๐Ÿงช Hands-on lab · 45 min

Docker โ€” Building Images

  1. 1. Your first Dockerfile
  2. 2. EXPOSE, CMD, and the build cache
  3. 3. Installing packages with RUN
  4. 4. Multi-stage builds
  5. 5. Push to a local registry

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.

© 2026 Cloud AI Campus