Emmanuel Akinfulubi

Containers all 4 written

What a container actually is, real Dockerfiles and images, and running more than one of them together.

11 What a container actually is

A virtual machine boots its own kernel and takes tens of seconds. A container starts in milliseconds because it is not booting anything -- it is a process, launched by the same kernel the host is already running, with a curtain drawn around what it can see. This lesson pulls the curtain back and shows what is actually behind it.

Inside the curtain: a container's process table has almost nothing in it

Every one of the hundreds of processes actually running on this machine is invisible from inside a container, by design. What a container sees when it asks "what is running" is only itself.

A container's process table, compared to the real machine's

ran at build time
echo "processes visible on the real machine right now:"
host_count=$(ps aux | wc -l | tr -d ' ')
echo "  $host_count lines from ps aux"

echo
echo "processes visible from INSIDE a fresh container:"
docker run --rm alpine:3.20 ps aux

container_count=$(docker run --rm alpine:3.20 ps aux | wc -l | tr -d ' ')
container_pid1=$(docker run --rm alpine:3.20 sh -c 'echo $$')

echo
echo "the container sees $container_count line(s) of output from ps aux"
echo "and its own process is PID $container_pid1 -- from inside its own view"

check "the host has far more real processes running than a container reports" \
  "$([ "$host_count" -gt "$container_count" ] && echo yes)" "yes"
check "every fresh container's main process believes it is PID 1" "$container_pid1" "1"

What it printed, when the build ran it

processes visible on the real machine right now:
  798 lines from ps aux

processes visible from INSIDE a fresh container:
PID   USER     TIME  COMMAND
    1 root      0:00 ps aux

the container sees 2 line(s) of output from ps aux
and its own process is PID 1 -- from inside its own view
ok   the host has far more real processes running than a container reports
ok   every fresh container's main process believes it is PID 1

PID 1 inside the container and some other PID on the host are the SAME process. Namespaces do not create a new process, they change what an existing one is allowed to see and address. The next panel proves this by asking the host what PID it actually gave the container.

The same process, seen from both sides

docker inspect asks the Docker daemon -- which lives on the host, outside any container -- for the real, host-level process ID it assigned. That number, and the process the container itself sees as PID 1 in its own namespace, are two views of exactly one running process.

One process, two PIDs, depending on where you ask from

ran at build time
docker run --rm -d --name cd-course-inspect alpine:3.20 sleep 30 > /dev/null

for _ in 1 2 3 4 5 6 7 8 9 10; do
  docker inspect cd-course-inspect > /dev/null 2>&1 && break
  sleep 0.2
done

host_pid=$(docker inspect cd-course-inspect --format '{{.State.Pid}}')
# 'docker exec' peeks INTO the container's existing namespace and asks its own
# ps what PID 1 there actually is running -- rather than spawning a brand new
# process and assuming it inherits PID 1, which it does not.
container_view=$(docker exec cd-course-inspect ps aux)
pid1_command=$(echo "$container_view" | awk '$1 == "1" {print $4; exit}')

echo "the host (via the Docker daemon) says this container's process is PID: $host_pid"
echo
echo "the SAME container, asked about itself from the inside:"
echo "$container_view"
echo "...PID 1 in that namespace is running: $pid1_command"

docker kill cd-course-inspect > /dev/null 2>&1
docker rm cd-course-inspect > /dev/null 2>&1

check "the host's PID for this process is a real, large process ID" \
  "$([ "$host_pid" -gt 1 ] && echo yes)" "yes"
check "inside its own namespace, the container's main process really is PID 1" "$pid1_command" "sleep"

What it printed, when the build ran it

the host (via the Docker daemon) says this container's process is PID: 42161

the SAME container, asked about itself from the inside:
PID   USER     TIME  COMMAND
    1 root      0:00 sleep 30
    7 root      0:00 ps aux
...PID 1 in that namespace is running: sleep
ok   the host's PID for this process is a real, large process ID
ok   inside its own namespace, the container's main process really is PID 1

Filesystem isolation: a container's root is not the host's root

The same trick applies to files. A container's / is whatever the image shipped, not the host's actual filesystem -- writing to /etc/hostname inside a container never touches the real machine's /etc/hostname at all.

A container's filesystem is not the host's filesystem

ran at build time
echo "the REAL machine's hostname file, untouched by anything below:"
real_hostname_before=$(cat /etc/hostname 2>/dev/null || hostname)
echo "  $real_hostname_before"

echo
echo "writing a completely different hostname INSIDE a container:"
docker run --rm alpine:3.20 sh -c 'echo totally-fake-name > /etc/hostname; cat /etc/hostname'

real_hostname_after=$(cat /etc/hostname 2>/dev/null || hostname)
echo
echo "the real machine's hostname file, checked again afterwards:"
echo "  $real_hostname_after"

check "the real machine's hostname file was never touched" "$real_hostname_after" "$real_hostname_before"

What it printed, when the build ran it

the REAL machine's hostname file, untouched by anything below:
  Emmanuels-MacBook-Pro.local

writing a completely different hostname INSIDE a container:
totally-fake-name

the real machine's hostname file, checked again afterwards:
  Emmanuels-MacBook-Pro.local
ok   the real machine's hostname file was never touched

What this is NOT: strong security isolation on the level of a real virtual machine. Containers share the host's actual kernel, so a kernel vulnerability can, in principle, be exploited to escape a container in a way that is architecturally impossible against a real VM's separate kernel. This is exactly why "run untrusted code" and "package our own trusted application" call for different levels of isolation, and why cloud providers offer both containers and VMs rather than only one.

Starting fast is not a trick -- it follows directly from what a container is

Because starting a container is "ask the kernel to start a process with some namespaces," not "boot a kernel," the startup time is dominated by however long the program itself takes to become ready -- often milliseconds for something small.

Timing a real container start, not a claimed number

ran at build time
echo "timing a real container's total start-to-finish time:"
start_ns=$(date +%s%N)
docker run --rm alpine:3.20 true
end_ns=$(date +%s%N)
elapsed_ms=$(( (end_ns - start_ns) / 1000000 ))
echo "alpine container, start to exit: ${elapsed_ms}ms"

check "starting and exiting a minimal container takes well under a second" \
  "$([ "$elapsed_ms" -lt 5000 ] && echo yes)" "yes"

What it printed, when the build ran it

timing a real container's total start-to-finish time:
alpine container, start to exit: 425ms
ok   starting and exiting a minimal container takes well under a second

Why 5 seconds, not 100ms, as the threshold above. This check runs during a real build, on whatever machine happens to be building the course, which may be loaded with other work. The point being verified is "this is process-speed, not boot-speed" -- a real VM boot is measured in tens of seconds even on fast hardware, so anything under five is already a categorically different thing, and picking a strict threshold here would make the check about this machine's mood, not about containers.

12 A real Dockerfile, and images as layers

A Dockerfile is a recipe for building an image one layer at a time, and each instruction becomes its own layer, cached independently. Understanding that one fact -- and specifically, the order the instructions are written in -- is most of what separates a Dockerfile that rebuilds in two seconds from one that reinstalls every dependency on every single change.

A real Dockerfile, building a real HTTP server

Five lines, and a genuine, working web server on the other end of it: a base image, a working directory, a file copied in, a port documented, and the command that starts it.

Building and running a real containerized web server

ran at build time
work=$(mktemp -d)
cd "$work"

cat > app.py << 'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()
        self.wfile.write(b'hello from a real container\n')

HTTPServer(('0.0.0.0', 8000), Handler).serve_forever()
PY

cat > Dockerfile << 'DOCKERFILE'
FROM python:3.12-alpine
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python3", "app.py"]
DOCKERFILE

echo "the Dockerfile:"
cat Dockerfile

echo
echo "building it for real:"
docker build -t cd-course-lesson12:v1 --progress=plain . 2>&1 | grep -E '^\#[0-9]+ (\[|naming|exporting to)'
build_exit=${PIPESTATUS[0]}

echo
echo "running it and making a real HTTP request against it:"
docker run --rm -d --name cd-course-l12 -p 18801:8000 cd-course-lesson12:v1 > /dev/null

response=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
  response=$(curl -s -f http://localhost:18801/ 2>/dev/null) && break
  sleep 0.3
done
echo "the container's real response: $response"

docker stop cd-course-l12 > /dev/null 2>&1
docker rmi cd-course-lesson12:v1 > /dev/null 2>&1
cd / && rm -rf "$work"

check "the image built without error" "$build_exit" "0"
check "a real HTTP request to the running container got the expected body" \
  "$response" "hello from a real container"

What it printed, when the build ran it

the Dockerfile:
FROM python:3.12-alpine
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python3", "app.py"]

building it for real:
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/python:3.12-alpine
#3 [internal] load .dockerignore
#4 [internal] load build context
#5 [1/3] FROM docker.io/library/python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a
#6 [2/3] WORKDIR /app
#7 [3/3] COPY app.py .
#8 exporting to image
#8 naming to docker.io/library/cd-course-lesson12:v1
#8 naming to docker.io/library/cd-course-lesson12:v1 done

running it and making a real HTTP request against it:
the container's real response: hello from a real container
ok   the image built without error
ok   a real HTTP request to the running container got the expected body

Layers, and the build cache that keys off them

Docker caches each instruction's result by hashing that instruction and everything it depends on. Change nothing, and a rebuild replays the cache instead of doing any real work -- shown here as CACHED against each step.

The same build, run twice: nothing cached, then everything cached

ran at build time
work=$(mktemp -d)
cd "$work"

# Content unique to this run: rebuilding this course must never let a layer
# cached by an earlier run make the "cold build" step look cached already.
unique="$(date +%s%N)"
echo "line one $unique" > app.py
cat > Dockerfile << DOCKERFILE
FROM alpine:3.20
WORKDIR /app
COPY app.py .
RUN echo "processed $unique" > /marker.txt
DOCKERFILE

echo "first build (nothing OF THIS DOCKERFILE'S OWN STEPS to reuse yet):"
first=$(docker build -t cd-course-l12b:v1 --progress=plain . 2>&1)
echo "$first" | grep -E '^\#[0-9]+ (\[|CACHED)'
# Scoped to COPY/RUN only: the base image (FROM) may legitimately already be
# cached from an earlier, unrelated pull on this machine, which is correct
# Docker behaviour, not something to hide behind a falsely "cold" assertion.
first_own_cached=$(echo "$first" | grep -E '(COPY|RUN)' -A1 | grep -c 'CACHED')

echo
echo "second build, with NO changes at all:"
second=$(docker build -t cd-course-l12b:v1 --progress=plain . 2>&1)
echo "$second" | grep -E '^\#[0-9]+ (\[|CACHED)'
second_own_cached=$(echo "$second" | grep -E '(COPY|RUN)' -A1 | grep -c 'CACHED')

docker rmi cd-course-l12b:v1 > /dev/null 2>&1
cd / && rm -rf "$work"

echo
echo "this Dockerfile's own steps cached on the cold build: $first_own_cached"
echo "this Dockerfile's own steps cached on the identical rebuild: $second_own_cached"

check "the first build does real work on its own COPY/RUN steps" "$first_own_cached" "0"
check "the identical rebuild reuses both of this Dockerfile's own steps" "$second_own_cached" "2"

What it printed, when the build ran it

first build (nothing OF THIS DOCKERFILE'S OWN STEPS to reuse yet):
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/alpine:3.20
#3 [internal] load .dockerignore
#4 [1/4] FROM docker.io/library/alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
#5 [internal] load build context
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY app.py .
#8 [4/4] RUN echo "processed 1789503443804367000" > /marker.txt

second build, with NO changes at all:
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/alpine:3.20
#3 [internal] load .dockerignore
#4 [internal] load build context
#5 [1/4] FROM docker.io/library/alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
#4 [internal] load build context
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY app.py .
#7 CACHED
#8 [4/4] RUN echo "processed 1789503443804367000" > /marker.txt
#8 CACHED

this Dockerfile's own steps cached on the cold build: 0
this Dockerfile's own steps cached on the identical rebuild: 2
ok   the first build does real work on its own COPY/RUN steps
ok   the identical rebuild reuses both of this Dockerfile's own steps

Instruction order decides how much of that cache survives a real change

This is the actual lesson inside the lesson: put whatever changes least often first. A dependency file changes rarely; application code changes constantly. Copying dependencies in and installing them before copying the code means an ordinary code change never invalidates the (often slow) install step.

Ordering a Dockerfile so an unrelated change stays cheap

ran at build time
work=$(mktemp -d)
cd "$work"

echo "# pretend dependency list" > requirements.txt
# Content unique to this run, so a repeat build of this course can never
# collide with a layer BuildKit cached from an earlier run of this exact
# panel -- which would make the "before" state falsely look cached already.
unique="$(date +%s%N)"
echo "print('version 1, $unique')" > app.py

cat > Dockerfile << 'DOCKERFILE'
FROM alpine:3.20
WORKDIR /app
COPY requirements.txt .
RUN echo "pretend: pip install -r requirements.txt" > /installed.marker
COPY app.py .
DOCKERFILE

docker build -t cd-course-l12c:v1 --progress=plain . > /dev/null 2>&1

echo "changing ONLY app.py -- requirements.txt is untouched:"
echo "print('version 2, unrelated to dependencies, $unique')" > app.py

rebuild=$(docker build -t cd-course-l12c:v1 --progress=plain . 2>&1)
echo "$rebuild" | grep -E '^\#[0-9]+ (\[|CACHED)'

deps_step_cached=$(echo "$rebuild" | grep -A1 'COPY requirements.txt' | grep -c 'CACHED')
install_step_cached=$(echo "$rebuild" | grep -A1 'RUN echo' | grep -c 'CACHED')
code_step_cached=$(echo "$rebuild" | grep -A1 'COPY app.py' | grep -c 'CACHED')

docker rmi cd-course-l12c:v1 > /dev/null 2>&1
cd / && rm -rf "$work"

echo
echo "dependency copy step cached: $deps_step_cached"
echo "the slow install step cached: $install_step_cached"
echo "the code copy step cached (it changed, so it should NOT be): $code_step_cached"

check "the dependency-copying step was reused, since requirements.txt did not change" "$deps_step_cached" "1"
check "the slow install step was reused too, for the same reason" "$install_step_cached" "1"
check "only the step that actually changed had to redo any work" "$code_step_cached" "0"

What it printed, when the build ran it

changing ONLY app.py -- requirements.txt is untouched:
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/alpine:3.20
#3 [internal] load .dockerignore
#4 [internal] load build context
#5 [1/5] FROM docker.io/library/alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
#6 [3/5] COPY requirements.txt .
#6 CACHED
#7 [2/5] WORKDIR /app
#7 CACHED
#8 [4/5] RUN echo "pretend: pip install -r requirements.txt" > /installed.marker
#8 CACHED
#9 [5/5] COPY app.py .

dependency copy step cached: 1
the slow install step cached: 1
the code copy step cached (it changed, so it should NOT be): 0
ok   the dependency-copying step was reused, since requirements.txt did not change
ok   the slow install step was reused too, for the same reason
ok   only the step that actually changed had to redo any work

This is not a micro-optimisation. On a real project with a genuine dependency install step -- npm install, pip install, a Go module download -- getting this order wrong means every single code change re-triggers a full dependency reinstall, turning a two-second rebuild into a two-minute one, on every push, for every developer, for the life of the project.

What actually ended up in the image

docker history lists every layer an image is made of, in the order the Dockerfile created them -- the real, on-disk consequence of every instruction above.

docker history: what an image is actually built from

ran at build time
work=$(mktemp -d)
cd "$work"
echo "app" > app.py
cat > Dockerfile << 'DOCKERFILE'
FROM alpine:3.20
WORKDIR /app
COPY app.py .
RUN echo "built" > /marker.txt
DOCKERFILE

docker build -t cd-course-l12d:v1 --progress=plain . > /dev/null 2>&1

echo "every layer this image is actually made of:"
docker history cd-course-l12d:v1 --no-trunc --format '{{.CreatedBy}}' | head -5

layer_count=$(docker history cd-course-l12d:v1 -q | wc -l | tr -d ' ')
echo
echo "total layers: $layer_count"

docker rmi cd-course-l12d:v1 > /dev/null 2>&1
cd / && rm -rf "$work"

check "the image really does have multiple distinct layers" "$([ "$layer_count" -ge 3 ] && echo yes)" "yes"

What it printed, when the build ran it

every layer this image is actually made of:
RUN /bin/sh -c echo "built" > /marker.txt # buildkit
COPY app.py . # buildkit
WORKDIR /app
CMD ["/bin/sh"]
ADD alpine-minirootfs-3.20.10-x86_64.tar.gz / # buildkit

total layers: 5
ok   the image really does have multiple distinct layers

13 Docker Compose: several containers as one system

Every container so far in this course has been alone. Compose's whole job is removing that limitation: one YAML file describing several services, brought up together, on a private network where each one can find the others by name -- not by an IP address you have to look up and hope does not change.

Two real services, and one reaching the other by name

An nginx web server and a Redis cache, defined in one file, started with one command. The interesting part is not that both start -- it is that web can reach cache by typing exactly that word, because Compose gives every service in the file its own DNS name on a private network it creates automatically.

One compose.yaml, two real services, real DNS between them

ran at build time
work=$(mktemp -d)
cd "$work"

cat > compose.yaml << 'YAML'
services:
  web:
    image: nginx:alpine
    ports:
      - "18900:80"
  cache:
    image: redis:alpine
YAML

echo "the compose file:"
cat compose.yaml

echo
echo "bringing both services up:"
docker compose -p cd-course-l13a up -d --quiet-pull
up_exit=$?

for _ in 1 2 3 4 5 6 7 8 9 10; do
  curl -s -f http://localhost:18900/ > /dev/null 2>&1 && break
  sleep 0.3
done

web_status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18900/)
echo
echo "a real HTTP request to the web service: status $web_status"

echo
echo "the web service reaching the cache service BY NAME, not by IP:"
ping_result=$(docker compose -p cd-course-l13a exec -T web ping -c1 cache 2>&1)
echo "$ping_result"

docker compose -p cd-course-l13a down > /dev/null 2>&1
cd / && rm -rf "$work"

check "compose brought both services up without error" "$up_exit" "0"
check "the web service actually answers HTTP requests" "$web_status" "200"
check "one service reaches another purely by service name" \
  "$(echo "$ping_result" | grep -c '1 packets transmitted, 1 packets received')" "1"

What it printed, when the build ran it

the compose file:
services:
  web:
    image: nginx:alpine
    ports:
      - "18900:80"
  cache:
    image: redis:alpine

bringing both services up:
 Network cd-course-l13a_default Creating 
 Network cd-course-l13a_default Creating 
 Network cd-course-l13a_default Created 
 Network cd-course-l13a_default Created 
 Container cd-course-l13a-web-1 Creating 
 Container cd-course-l13a-cache-1 Creating 
 Container cd-course-l13a-cache-1 Created 
 Container cd-course-l13a-web-1 Created 
 Container cd-course-l13a-cache-1 Starting 
 Container cd-course-l13a-web-1 Starting 
 Container cd-course-l13a-cache-1 Started 
 Container cd-course-l13a-web-1 Started 

a real HTTP request to the web service: status 200

the web service reaching the cache service BY NAME, not by IP:
PING cache (172.21.0.2): 56 data bytes
64 bytes from 172.21.0.2: seq=0 ttl=64 time=0.217 ms

--- cache ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.217/0.217/0.217 ms
ok   compose brought both services up without error
ok   the web service actually answers HTTP requests
ok   one service reaches another purely by service name

This is the entire reason Compose exists over a plain docker run for anything with more than one container. Making two bare containers talk by name needs a manually created Docker network and manual --network flags on every run. Compose does both automatically, just from the services being listed in the same file.

Sharing real data between two containers with a volume

A named volume is storage that outlives any single container and can be mounted into more than one at a time. Here, one throwaway container writes a value; a second, completely separate container reads that exact value back through the shared volume.

A named volume, and the $$ gotcha in a compose file

ran at build time
work=$(mktemp -d)
cd "$work"

cat > compose.yaml << 'YAML'
services:
  writer:
    image: alpine:3.20
    environment:
      - SHARED_MESSAGE=hello-from-compose-env
    command: sh -c "echo $$SHARED_MESSAGE > /shared/message.txt && sleep 2"
    volumes:
      - shared-data:/shared
  reader:
    image: alpine:3.20
    command: sh -c "sleep 1 && cat /shared/message.txt"
    volumes:
      - shared-data:/shared
    depends_on:
      - writer

volumes:
  shared-data:
YAML

echo "note the double dollar sign, \$\$SHARED_MESSAGE, in the compose file above:"
echo "a single \$ would be substituted by COMPOSE ITSELF, from the shell running"
echo "'docker compose', before the container ever sees it -- \$\$ is how you tell"
echo "Compose 'leave this one alone, let the CONTAINER's shell expand it instead.'"

docker compose -p cd-course-l13b up --quiet-pull > /tmp/cd-l13b.log 2>&1
compose_exit=$?

reader_output=$(docker compose -p cd-course-l13b logs reader 2>&1 | grep -v Attaching)
echo
echo "what the reader container actually read back, from a completely separate container's write:"
echo "$reader_output"

docker compose -p cd-course-l13b down -v > /dev/null 2>&1
cd / && rm -rf "$work" /tmp/cd-l13b.log

check "the compose run completed" "$compose_exit" "0"
check "the reader saw the exact value the writer set, through a shared volume" \
  "$(echo "$reader_output" | grep -c 'hello-from-compose-env')" "1"

What it printed, when the build ran it

note the double dollar sign, $$SHARED_MESSAGE, in the compose file above:
a single $ would be substituted by COMPOSE ITSELF, from the shell running
'docker compose', before the container ever sees it -- $$ is how you tell
Compose 'leave this one alone, let the CONTAINER's shell expand it instead.'

what the reader container actually read back, from a completely separate container's write:
reader-1  | hello-from-compose-env
ok   the compose run completed
ok   the reader saw the exact value the writer set, through a shared volume

The $$ above is a genuinely common gotcha, not decoration. Compose performs its own variable substitution on the YAML file before any container starts. Write a single $VARNAME intending the container's shell to expand it, and Compose usually expands it first instead -- often to nothing, silently, which is exactly what the warning in the raw output of the broken version of this panel said while this lesson was being written: the "SHARED_MESSAGE" variable is not set.

depends_on controls start order, not readiness

The compose file above used depends_on: [writer] so reader starts after writer is created -- but "created" is not "finished writing the file." The sleep 1 inside reader's own command is what actually gives writer time to do its work; depends_on alone would not have been enough.

  • depends_on — starts B after A has started. Does not wait for A to be ready to accept work.
  • depends_on: condition: service_healthy — the real fix, if A defines a healthcheck: now B waits for A to report healthy, not merely started.
  • Without either, the application itself must retry its own connection on startup -- the same discipline a real Kubernetes Pod needs, covered in Part 11.

14 Registries, tags and shipping an image

"Push an image" and "pull an image" sound like Docker-specific magic. They are HTTP requests against a documented API -- the OCI Distribution Spec -- which is exactly why Docker Hub, ECR, GHCR and a registry you run yourself all work with the same docker push/pull commands. This lesson runs the cheapest possible proof: a real registry, containerized, with nothing hidden.

A registry, self-hosted, receiving a real push and a real pull

registry:2 is the official, open-source reference implementation -- literally the same software Docker Hub itself is built from. Starting one locally costs nothing and needs no account.

A real push, a real removal, a real pull -- against your own registry

ran at build time
docker run -d -p 15000:5000 --name cd-course-registry registry:2 > /dev/null

for _ in 1 2 3 4 5 6 7 8 9 10; do
  curl -s -f http://localhost:15000/v2/ > /dev/null 2>&1 && break
  sleep 0.3
done

echo "tagging a real, already-pulled image for this registry:"
docker tag alpine:3.20 localhost:15000/course-image:v1

echo
echo "pushing it for real:"
docker push localhost:15000/course-image:v1 2>&1 | grep -v "^[0-9a-f]*: \(Unavailable\|Preparing\|Waiting\)"

echo
echo "removing the local copy, so the next pull cannot be cheating from local cache:"
docker rmi localhost:15000/course-image:v1 > /dev/null 2>&1

no_local_copy=$(docker images -q localhost:15000/course-image:v1)
echo "local copy after removal: '$no_local_copy' (empty means really gone)"

echo
echo "pulling it back from the registry:"
docker pull localhost:15000/course-image:v1 2>&1 | tail -3

pulled_id=$(docker images -q localhost:15000/course-image:v1)
echo "local copy after pulling back: $pulled_id"

docker rmi localhost:15000/course-image:v1 > /dev/null 2>&1
docker stop cd-course-registry > /dev/null 2>&1
docker rm cd-course-registry > /dev/null 2>&1

check "the local copy was genuinely removed before pulling" "$no_local_copy" ""
check "the pull actually brought a real image back" "$([ -n "$pulled_id" ] && echo yes)" "yes"

What it printed, when the build ran it

tagging a real, already-pulled image for this registry:

pushing it for real:
The push refers to repository [localhost:15000/course-image]
25f1d6b1951a: Pushed
v1: digest: sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e size: 1023

 Info -> Not all multiplatform-content is present and only the available single-platform image was pushed
         sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc -> sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e

removing the local copy, so the next pull cannot be cheating from local cache:
local copy after removal: '' (empty means really gone)

pulling it back from the registry:
Digest: sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e
Status: Downloaded newer image for localhost:15000/course-image:v1
localhost:15000/course-image:v1
local copy after pulling back: c64c687cbea9
ok   the local copy was genuinely removed before pulling
ok   the pull actually brought a real image back

Underneath the docker command: it really is just HTTP

The registry exposes a plain, documented HTTP API. Nothing here needs the docker CLI at all -- curl against the same endpoints Docker itself calls gets the same answers.

The registry API directly, with curl, no docker CLI involved

ran at build time
docker run -d -p 15001:5000 --name cd-course-registry2 registry:2 > /dev/null

for _ in 1 2 3 4 5 6 7 8 9 10; do
  curl -s -f http://localhost:15001/v2/ > /dev/null 2>&1 && break
  sleep 0.3
done

echo "the registry's catalog, before anything is pushed:"
before=$(curl -s http://localhost:15001/v2/_catalog)
echo "$before"

docker tag alpine:3.20 localhost:15001/curl-visible:v1
docker push localhost:15001/curl-visible:v1 > /dev/null 2>&1

echo
echo "the same catalog endpoint, plain curl, after a real push:"
after=$(curl -s http://localhost:15001/v2/_catalog)
echo "$after"

echo
echo "the tags for that one repository, again via plain HTTP:"
tags=$(curl -s http://localhost:15001/v2/curl-visible/tags/list)
echo "$tags"

docker rmi localhost:15001/curl-visible:v1 > /dev/null 2>&1
docker stop cd-course-registry2 > /dev/null 2>&1
docker rm cd-course-registry2 > /dev/null 2>&1

check "the catalog starts empty" "$before" '{"repositories":[]}'
check "curl alone (no docker CLI) can see the pushed image afterwards" \
  "$(echo "$after" | grep -c 'curl-visible')" "1"
check "the tag we pushed shows up via the same plain HTTP endpoint" \
  "$(echo "$tags" | grep -c '"v1"')" "1"

What it printed, when the build ran it

the registry's catalog, before anything is pushed:
{"repositories":[]}

the same catalog endpoint, plain curl, after a real push:
{"repositories":["curl-visible"]}

the tags for that one repository, again via plain HTTP:
{"name":"curl-visible","tags":["v1"]}
ok   the catalog starts empty
ok   curl alone (no docker CLI) can see the pushed image afterwards
ok   the tag we pushed shows up via the same plain HTTP endpoint

This is why multi-cloud image portability is realistic. An image pushed to Amazon ECR, Google Artifact Registry or a self-hosted registry is the same bytes in the same format, reachable through the same API shape. Moving a workload between clouds is rarely blocked by the image itself -- it is blocked by everything around it: IAM, networking, managed service equivalents.

Tags are labels, not versions -- and :latest is not special

A tag is just a name pointing at a specific image, the same way a Git branch points at a commit. Nothing enforces that :latest means "the newest" -- it means whatever was last pushed with that literal tag, which is exactly why relying on it in production is a common, avoidable source of "it worked yesterday."

:latest is a label like any other, pointing wherever it was last pushed

ran at build time
docker tag alpine:3.20 course-tag-demo:latest
docker tag alpine:3.20 course-tag-demo:v1

id_latest=$(docker images -q course-tag-demo:latest)
id_v1=$(docker images -q course-tag-demo:v1)

echo "course-tag-demo:latest -> $id_latest"
echo "course-tag-demo:v1     -> $id_v1"
echo "(both point at the same image right now, because both were just tagged from the same source)"

docker rmi course-tag-demo:latest course-tag-demo:v1 > /dev/null 2>&1

check "two different tags can point at the exact same image" "$id_latest" "$id_v1"

What it printed, when the build ran it

course-tag-demo:latest -> d9e853e87e55
course-tag-demo:v1     -> d9e853e87e55
(both point at the same image right now, because both were just tagged from the same source)
ok   two different tags can point at the exact same image

The practical rule this leads to: deploy by a specific, immutable tag or digest (a version number, a Git commit hash, or the image's own sha256:... digest) -- never by :latest -- so that "which exact image is running in production" is always an answerable question, not a guess about when someone last pushed.

What a real cloud registry adds on top of the open protocol

Shell

shown, not run
# Authenticating to and pushing into a real cloud registry -- Amazon ECR here.
# NOT RUN HERE: this needs a real AWS account with ECR access, which moto (this
# course's local AWS stand-in) does not emulate for actual image storage.

aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com

docker tag my-app:v1 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:v1
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:v1

# Everything after 'docker login' is identical to pushing to the self-hosted
# registry above -- same protocol, same commands. What ECR actually adds is
# IAM-based access control, vulnerability scanning, and lifecycle policies
# that auto-delete old images -- not a different way of storing them.

Needs a real AWS account with ECR access. moto emulates AWS APIs for compute/storage/database services but does not provide real image storage for ECR, so this cannot run inside this course's build.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.