Docker 07 — Images in Production
You built a SplitEase image and ran it on your laptop. Great — but a laptop isn’t where users find your app. The image has to leave your machine, land somewhere a server can grab it, and run there unchanged. This module is the bridge: how an image travels from your editor to a registry to a live server, and the two things that wreck that journey for beginners — sloppy tags and bloated, insecure images.
The Goal
By the end of this module you can:
- Explain what a registry is and how
docker login,tag,push, andpullmove an image between machines - Read an image name —
registry/namespace/name:tag— piece by piece, and know which part is which - Choose between a
:latesttag, a pinned version tag, and an immutable@sha256:digest, and say why one is reproducible and one is a trap - Shrink and harden an image — small base, multi-stage, non-root user, vulnerability scan — and explain why each one matters in production
- Draw the handoff: who builds the image, who stores it, who runs it — and stay in your lane instead of re-teaching pipelines or cloud
The Lesson
A registry is a shared shelf for images
You’ve built images locally with docker build. They live on your machine’s disk and nowhere else. A registry is a server whose whole job is to store images so other machines can download them. That’s it — a shared shelf.
You already use one without thinking about it. Every time you wrote FROM eclipse-temurin:21 in a Dockerfile, Docker pulled that base image from the default registry, Docker Hub (hub.docker.com). Pushing your own image is the same mechanism in reverse: you put your sealed box on the shelf so a CI server, a teammate, or a cloud box can pull it down and run it.
The two registries you’ll actually use as a beginner:
| Registry | Host | Why pick it |
|---|---|---|
| Docker Hub | docker.io (implied default) | The default, biggest public library, free public repos, simple login |
| GitHub Container Registry (GHCR) | ghcr.io | Lives next to your code on GitHub, free, easy to wire into GitHub Actions later |
Both are free for what you need. GHCR is the better long-term habit because your image ends up sitting right beside the repo it came from.
The four commands that move an image
Four commands do the whole round trip. Here’s the loop, then each one.
docker login ghcr.io # 1. prove who you are to the registry
docker tag splitease-api:latest \
ghcr.io/darshan/splitease-api:1.0.0 # 2. give it a full registry address
docker push ghcr.io/darshan/splitease-api:1.0.0 # 3. upload it to the shelf
docker pull ghcr.io/darshan/splitease-api:1.0.0 # 4. download it somewhere else
docker login— hands your credentials to the registry so it’ll accept your uploads. For GHCR you log in with your GitHub username and a Personal Access Token (PAT), not your password — more on that in Build This.docker tag— does NOT make a copy. It pins a second name on the same image so Docker knows where to push it. A local image calledsplitease-api:latestmeans nothing to a registry;ghcr.io/darshan/splitease-api:1.0.0is a full address.docker push— uploads the image to the registry, layer by layer. Layers it already has, it skips — that’s the layer cache from Docker 02 paying off again, this time over the network.docker pull— the reverse, run on any other machine: download the image so you can run it.
The mental model: build once, push once, pull-and-run anywhere. You never rebuild on the server. The exact bytes you tested locally are the exact bytes that run in production.
Reading an image name, piece by piece
This trips up every beginner because the parts are optional and Docker silently fills in defaults. Take this full name apart:
ghcr.io / darshan / splitease-api : 1.0.0
└─────┘ └─────┘ └───────────┘ └───┘
registry namespace image name tag
- registry —
ghcr.io. Where the image lives. Leave it out and Docker assumes Docker Hub (docker.io). That’s whyFROM postgres:16works with no registry — it’s quietlydocker.io/library/postgres:16. - namespace —
darshan. Whose image it is — your GitHub or Docker Hub username/org. Official images likepostgreslive under the speciallibrarynamespace, which is why you don’t type it. - image name —
splitease-api. The repository name. - tag —
1.0.0. Which version of the image. Leave it out and Docker assumes:latest. That assumption is the trap in the next section.
So postgres and ghcr.io/darshan/splitease-api:1.0.0 are the same shape — the short one just leans on three defaults.
Tags vs digests — why :latest is a trap
A tag is a sticky note on an image — a human-friendly name like 1.0.0 or latest. The critical thing beginners miss: a tag is a label that can move. Today :latest points at the image you just pushed. Tomorrow you push again, and :latest now points at the new image. Same name, different bytes.
That’s fine on your laptop. It’s a disaster for a deploy. Picture this:
- Monday you push
splitease-api:latestand your server pulls it. Works. - Wednesday a teammate pushes a broken build to
splitease-api:latest. - Friday the server restarts, pulls
:latestagain — and now runs the broken image. You changed nothing and it broke.
The name was the same; what the name pointed at moved under you. :latest is not “the newest stable version” — it’s just the default tag, and it means “whatever was pushed last.” Never deploy :latest.
Two fixes, in order of strength:
| You reference by… | Reproducible? | Example |
|---|---|---|
:latest | No — moves on every push | splitease-api:latest |
| a pinned version tag | Mostly — unless someone overwrites that tag | splitease-api:1.0.0 |
| an immutable digest | Yes — the bytes can never change | splitease-api@sha256:9b2c... |
A digest is a sha256 hash of the image’s exact content — its fingerprint. @sha256:9b2c... doesn’t name a version, it names these specific bytes. If even one byte differs, the hash differs, so a digest can never silently point at something else. You get a digest from docker pull output, or docker inspect, or the registry UI. Production deploys and CI pin digests precisely because they can’t move.
Rule of thumb: tag a version for humans (1.0.0), pin a digest for machines (@sha256:...). Use a moving tag and “it worked yesterday” stops being a sentence you can trust.
Smaller images: faster deploys, smaller attack surface
When the server pulls your image over the network, size is pull time. A 700 MB image is slow to push, slow to pull, slow to start, and — the part beginners skip — every megabyte is more software that can have a security hole. A fat image ships a whole Linux distro full of shells, package managers, and libraries your app never calls. Each of those is something an attacker could exploit. This is your attack surface: the total amount of stuff in the image that could be a way in.
Three levers shrink it (all taught hands-on in Docker 03 — this is the why it matters in production angle):
| Lever | What it does | Rough effect |
|---|---|---|
| Alpine base | eclipse-temurin:21-jre-alpine instead of full Debian — a tiny ~5 MB Linux | Cuts hundreds of MB |
| Multi-stage build | Build the JAR in a fat stage, copy only the JAR into a slim final stage — no Maven, no source in the shipped image | Drops the whole build toolchain |
| Distroless | Google’s images with no shell, no package manager — just your app and the runtime | Smallest, hardest to attack |
Distroless is the production end-state: with no shell inside, an attacker who breaks into the container can’t even open a terminal. The trade-off — you can’t docker exec ... sh into it to poke around, so debug locally with a normal image and ship distroless.
Don’t run as root
By default a container’s process runs as root inside the container. If an attacker escapes the app, they’re root in there — and a root escape is far more dangerous than a normal-user one. The fix is one habit, set in the Dockerfile (shown in Docker 03): create a normal user and switch to it before the app runs.
RUN useradd --system --no-create-home --uid 10001 appuser
USER appuser
Now SplitEase runs as appuser, not root. If something gets in, it’s stuck as a low-privilege user with nothing to break. This is a near-free, one-line security win that production reviewers will check for.
Scan your image for known vulnerabilities
Your image bundles dozens of OS packages and libraries. Some of those will have publicly known vulnerabilities (CVEs — catalogued security flaws). A scanner reads everything in your image, matches it against the CVE databases, and tells you what’s exploitable before an attacker does.
docker scout cves splitease-api:1.0.0 # built into Docker Desktop
trivy image splitease-api:1.0.0 # Trivy — free, open-source, very popular
docker scout— built into modern Docker Desktop, zero setup, good first scan. (The olderdocker scanwas its Snyk-based predecessor; Scout is what you use now.)- Trivy — free and open-source, the de-facto standard in CI pipelines. Drop it into a build and fail the build if a critical CVE shows up.
The output ranks findings Critical → High → Medium → Low. The realistic move as a beginner: scan, fix Criticals and Highs (often just by bumping to a newer, patched base image), and don’t lose sleep over every Low. The reason this matters in a real job: shipping a known-vulnerable image is how breaches start, and “I never scanned it” is not a sentence you want to say after one.
The handoff — who does what
Here’s the part that keeps Docker in its lane. Three jobs, three owners, and you should never confuse them:
The CI/CD track (../ci-cd/overview) owns BUILD and PUSH — a pipeline runs docker build and docker push automatically on every commit, so a human never does it by hand. A registry (Docker Hub or GHCR) just STORES the image on its shelf. The Cloud track (../cloud/deploying-to-cloud) owns PULL and RUN — a rented server pulls the pinned image and runs it for real users. Docker — this track — gives you the image and the local know-how; it does not run your pipeline and it does not run your server. Learn the seam, then link across it.
flowchart LR
A["Build image - CI"] --> B["Push to registry"]
B --> C["Registry stores it"]
C --> D["Pull image - Cloud"]
D --> E["Run container - Cloud"]
You own building a correct image and knowing how push and pull work. CI automates it. The cloud consumes it. Don’t re-teach yourself pipelines or deploys here — when you’re ready, the CI/CD track and the Cloud track pick up exactly where this arrow leaves off.
Build This
Take your SplitEase image, push it to a registry, then pull it fresh on a different machine and run it. When it runs from a remote pull, you’ve done the whole production journey. We’ll use GitHub Container Registry (GHCR) — free, and it sits next to your repo.
Definition of done: the splitease-api image exists in a registry, and you run it after pulling it from there (not from your local build).
1. Make a token and log in
GHCR doesn’t take your GitHub password. Create a Personal Access Token (PAT): GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate, with the write:packages scope ticked. Copy it.
# replace YOUR_GH_USERNAME; paste the PAT when prompted for a password
docker login ghcr.io -u YOUR_GH_USERNAME
Expected: Login Succeeded.
2. Tag the image with a full GHCR address
Assuming you built splitease-api in an earlier module (if not, docker build -t splitease-api:latest . in the SplitEase repo first):
docker tag splitease-api:latest ghcr.io/YOUR_GH_USERNAME/splitease-api:1.0.0
Note the version tag 1.0.0, not :latest — you’re practising the production habit from the start.
3. Push it
docker push ghcr.io/YOUR_GH_USERNAME/splitease-api:1.0.0
Expected: layers upload, ending in a digest: sha256:... line. Copy that digest — it’s the immutable fingerprint. Your image is now on the shelf. (By default new GHCR packages are private; that’s fine for this drill.)
4. Pull it fresh and run it
Now act like a server that’s never seen this image. Easiest free way: open Play with Docker (labs.play-with-docker.com), start a session, and there docker login ghcr.io again (a private package needs auth), then:
docker pull ghcr.io/YOUR_GH_USERNAME/splitease-api:1.0.0
docker run -p 8080:8080 ghcr.io/YOUR_GH_USERNAME/splitease-api:1.0.0
Or simulate it on your own machine: delete the local image first (docker rmi ghcr.io/YOUR_GH_USERNAME/splitease-api:1.0.0) so the run is forced to pull from remote.
Done when: the app boots from a pulled-from-remote image. You just shipped a box from your laptop to a registry and ran it somewhere it had never existed before — the exact thing CI and the cloud do for you automatically later.
Bonus: scan before you trust it
docker scout cves ghcr.io/YOUR_GH_USERNAME/splitease-api:1.0.0
Read the Critical/High count. If it’s ugly, the fastest fix is usually a newer JRE base image — rebuild, re-tag, re-push.
Where to Practice
| What | Where | Time |
|---|---|---|
| Create a free registry, push your first image | hub.docker.com | 20 min |
| Push to GitHub Container Registry (PAT + ghcr.io) | docs.github.com (search “working with the Container registry”) | 30 min |
| Pull-and-run your pushed image on a clean machine | labs.play-with-docker.com | 20 min |
| Tags, digests, push/pull reference | docs.docker.com (search “docker push”, “image tag”) | 20 min |
| Practise on the real thing | your own SplitEase repo image | ongoing |
How to Practice
Do the full loop end to end at least twice — tag, push, then pull-and-run on a machine that’s never had the image (Play with Docker is perfect, it’s a guaranteed clean box). The first time you’ll fumble the login token; the second time it’ll feel like nothing. Then deliberately push a second build to the same :latest tag and watch the old one get shadowed — feeling that tag move under you is what makes “never deploy latest” stick for good.
Check Yourself
What is a registry, in one sentence?
A server whose job is to store images so other machines can pull and run them — a shared shelf for images. Docker Hub and GHCR are registries.
In `ghcr.io/darshan/splitease-api:1.0.0`, name each of the four parts.
ghcr.io = registry (where), darshan = namespace (whose), splitease-api = image name, 1.0.0 = tag (which version).
Why is deploying `:latest` dangerous?
:latest is a movable label meaning “whatever was pushed last,” not “newest stable.” A new push repoints it, so the next pull on the server can run different — possibly broken — bytes even though nothing on the server changed.
What is an image digest and why is it reproducible?
A sha256 hash of the image’s exact contents — its fingerprint. If any byte changes, the hash changes, so @sha256:... can never silently point at different bytes. That’s why production pins digests.
Name two reasons a smaller image is better in production.
It deploys faster (less to push/pull/start), and it has a smaller attack surface — less bundled software means fewer things an attacker can exploit. Alpine, multi-stage, and distroless all shrink it.
Why run the container as a non-root user?
Containers run as root by default. If an attacker escapes your app, being root inside the container is far more dangerous than being a normal user. Creating a user and USER appuser in the Dockerfile limits the blast radius — a near-free win.
What does an image scanner like docker scout or Trivy do?
It inventories every package and library in your image and matches them against known-vulnerability (CVE) databases, ranking findings Critical→Low — so you find exploitable holes before an attacker does. Fix Criticals and Highs, usually by bumping the base image.
Who builds, who stores, who runs the image?
CI/CD builds and pushes it on every commit; a registry stores it; the Cloud pulls and runs it for users. Docker (this track) gives you a correct image and the local know-how — it doesn’t run your pipeline or your server.
Still Unclear?
Copy-paste any of these to Claude to go deeper:
- “Walk me through exactly what bytes move over the network during
docker pushand how the layer cache decides what to skip. Use my SplitEase image as the example.” - “Show me a Dockerfile difference, line by line, between an image that runs as root and one that runs as a non-root
appuser, and explain what an attacker can and can’t do in each.” - “I ran
docker scout cveson my SplitEase image and it lists 3 High CVEs in the base image. Explain how to read this output and the safest order to fix them without breaking the build.”
Why AI Can’t Do This For You
AI can hand you the tag/push/pull commands, but it can’t make the production judgment calls — whether 1.0.0 or a digest is right for this deploy, which scanner findings genuinely matter versus noise you can defer, how small is small enough before you’re spending hours for nothing. When a fresh server pulls a six-month-old :latest and runs the wrong build at 2am, the person who understands why the tag moved fixes it in minutes; the person who only copied commands is stuck. That understanding is the skill, and it’s earned by pushing, pulling, and breaking it yourself.
Module done? Add it to today’s tracker