Image #
The Docker Image is the main foundation of the entire Docker ecosystem. Every running container — without exception — always comes from an image. An image is an immutable template that can be moved, versioned, cached, and shared across machines and clouds. Understanding images deeply isn’t just theoretical knowledge; it determines how small, secure, and fast your deployment pipeline is.
This article covers images from three angles: conceptual (what an image really is), technical (how images are structured and read), and practical (how to build efficient images for production). After reading this article, you’ll be able to explain why image layers work the way they do, how multi-stage builds can cut image size by up to 90%, and when to use a particular base image.
Image as a Blueprint #
An image is a filesystem snapshot plus metadata. It isn’t one monolithic file; it’s a collection of layers, each storing the changes from the layer below it. Combined with metadata like default commands, exposed ports, and environment variables, an image becomes a complete package that can run anywhere.
flowchart LR
subgraph IMAGE["Docker Image (immutable)"]
L1[Layer 1: base OS - ubuntu:22.04]
L2[Layer 2: apt install nginx]
L3[Layer 3: COPY config]
L4[Layer 4: CMD metadata]
end
IMAGE -->|"docker run"| CONTAINER["Docker Container<br/>(runtime instance)"]
subgraph CONTAINER_RUNTIME["Container Runtime"]
WRITABLE["Writable Layer<br/>(runtime changes)"]
IMAGE_RO["Image Layers (read-only, shared)"]
end
CONTAINER --> CONTAINER_RUNTIMENotice the important difference: images are immutable, containers are mutable (in the sense that they have a writable layer). An image never changes after being built. A container can change while running, but once deleted, all its changes disappear — the image remains intact to run again.
A common analogy: an image is an OS installer ISO, a container is the running OS installed from that ISO. The ISO doesn’t change when you install it on a laptop; it just becomes an instance that can be modified by usage.
Interesting detail: “immutable” isn’t just marketing jargon. Image immutability is what makes deployments reproducible — the image you build today, if pulled three years from now, will produce an exactly identical container. That’s something VM snapshots or traditional installers can’t guarantee.
Layered Architecture — The Image Foundation #
Every Docker image is made up of layers stacked on top of each other. Layers are the core of all the optimizations we’ll discuss next.
How Layers Are Formed #
Each Dockerfile instruction produces one new layer. Docker records the filesystem before and after the instruction and stores only the delta.
FROM ubuntu:22.04 # Layer 1: ubuntu:22.04 image (pulled from registry)
RUN apt-get update # Layer 2: delta from /var/lib/apt
RUN apt-get install -y nginx # Layer 3: delta from the nginx installation
COPY ./app /app # Layer 4: delta from COPYing files
CMD ["nginx", "-g", "daemon off;"] # Metadata only, NOT a layer
The important takeaways from the example above:
FROMcreates a layer from an existing image (usually a base image). This layer is often large (ubuntu:22.04 is about 77 MB).RUN,COPY,ADDcreate new layers from the command’s execution results.CMD,ENTRYPOINT,ENV,EXPOSE,LABELare metadata — they don’t create new layers, only add entries to the image configuration.
Build Cache — Why Layers Matter for Performance #
flowchart TD
A[docker build starts] --> B{Cache hit<br/>for layer 1?}
B -- Yes --> C[Use layer 1 from cache]
B -- No --> D[Build layer 1 from scratch]
C --> E{Cache hit<br/>for layer 2?}
D --> E
E -- Yes --> F[Use layer 2 from cache]
E -- No --> G[Build layer 2]
F --> H{Cache hit<br/>for layer 3?}
G --> H
H -- Yes --> I[Use layer 3 from cache]
H -- No --> J[Build layer 3]This is why the order of instructions in a Dockerfile matters so much. Docker compares each layer against the cache using the checksum of the instruction AND the referenced file contents. If you change one line of code in a COPY, every layer after it is invalidated and rebuilt.
# ANTI-PATTERN: source code changes → every layer after COPY is invalidated
FROM node:20
RUN apt-get install -y curl # layer A
COPY . /app # layer B - changes on every commit
RUN npm install # layer C - INVALIDATED on every build
CMD ["node", "/app/server.js"] # metadata
# CORRECT: separate dependency installation from source code
FROM node:20
RUN apt-get install -y curl # layer A
COPY package*.json /app/ # layer B - only changes when deps change
RUN npm install # layer C - cached until package.json changes
COPY . /app # layer D - changes often, but only invalidates 1 layer
CMD ["node", "/app/server.js"] # metadata
The main principle: put instructions that rarely change at the top (base image, system packages), and those that change often (source code, config) at the bottom. This maximizes cache hits and minimizes build time.
Image Manifest and Configuration #
An image isn’t just filesystem layers. It also has a manifest and configuration that describe those layers.
Image Manifest (OCI Image Manifest Spec) #
The manifest is JSON stored in the registry that describes the image:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"digest": "sha256:b5b2b2c507a0944348e0303112d8d08..."
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:e692418e4d3c..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:3e3b1767b397..."
}
]
}
Important fields:
config.mediaTypeanddigest— pointers to the image configuration JSON (which holds CMD, ENV, etc.).layers[]— the list of layers, each with a content-addressable digest (sha256:...). This digest is what makes image integrity verifiable.
Image Config (Image Details) #
The image configuration stores the metadata executed when a container starts. Some key fields:
| Field | Function |
|---|---|
Cmd | Default command + arguments |
Entrypoint | The executable to run |
Env | Environment variables (KEY=value) |
ExposedPorts | Ports documented as “accessible” |
WorkingDir | Default working directory |
User | Default UID/GID for processes |
Volumes | Default mount points |
Architecture | Target architecture (amd64, arm64, etc.) |
Os | Target OS (linux, windows) |
Rootfs.diff_ids | Layer digest list (bottom to top order) |
Multi-Architecture Images #
A manifest can point to a manifest list (or “fat manifest”) that combines images for different architectures. That’s why docker pull nginx works on both an Apple Silicon laptop (arm64) and an Intel server (amd64).
flowchart TB
TAG["nginx:latest (tag)"]
LIST["Manifest List"]
TAG --> LIST
LIST --> AMD64["Image: linux/amd64<br/>(sha256:aaa...)"]
LIST --> ARM64["Image: linux/arm64<br/>(sha256:bbb...)"]
LIST --> ARMv7["Image: linux/arm/v7<br/>(sha256:ccc...)"]
LIST --> PPC64LE["Image: linux/ppc64le<br/>(sha256:ddd...)"]At runtime, Docker detects the host architecture (via uname -m) and picks the matching image from the manifest list. This happens transparently — you don’t need to know the details.
Building for multiple architectures: use docker buildx with the --platform flag. Buildx creates a special builder instance that can do emulation (QEMU) or multi-node builds, then combines the results into one manifest list pushed to the registry.
Multi-Stage Builds — Trimming Image Size #
Multi-stage builds are one of the most impactful Docker techniques. They let you separate the build environment (with compilers, tools, intermediate artifacts) from the runtime environment (which only contains the final binary and minimal dependencies).
Multi-Stage Build Anatomy #
flowchart LR
subgraph STAGE1["Stage 1: builder"]
S1_BASE["FROM golang:1.22-alpine<br/>(300+ MB)"]
S1_CODE["COPY . ."]
S1_BUILD["RUN go build -o app<br/>(20 MB binary)"]
end
subgraph STAGE2["Stage 2: runtime"]
S2_BASE["FROM gcr.io/distroless/base<br/>(20 MB)"]
S2_COPY["COPY --from=builder /app/app /"]
end
S1_BASE --> S1_CODE --> S1_BUILD
S2_BASE --> S2_COPY
S1_BUILD -.->|"binary only<br/>20 MB"| S2_COPYWhat happens behind the scenes:
- Stage 1 uses a large image (Go SDK 300+ MB) to compile the code.
- Stage 2 starts from a minimal image (distroless, 20 MB).
COPY --from=buildercopies only the final binary from stage 1 to stage 2.- Stage 1 (with all its compilers and intermediate artifacts) is discarded and doesn’t enter the final image.
Practical Example #
# Stage 1: build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server
# Stage 2: runtime
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/server /app/server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/server"]
The final image:
- Size: ~10–15 MB (vs ~350 MB without multi-stage)
- Attack surface: minimal (no shell, no package manager)
- User: nonroot (the default in distroless)
- Binary: symbols already stripped (
-s -w)
Image Size Comparison #
| Base Image | Typical Size | Use Case |
|---|---|---|
ubuntu:22.04 | ~77 MB | General purpose, lots of tools |
node:20 | ~900 MB | Node.js development |
node:20-alpine | ~130 MB | Node.js production |
python:3.12 | ~1 GB | Python development |
python:3.12-slim | ~150 MB | Python production |
gcr.io/distroless/base | ~20 MB | Production compiled binaries |
gcr.io/distroless/static | ~2 MB | Static Go/Rust binaries |
scratch | 0 MB | Minimal static binaries |
A common trap: choosing a base image only because it’s the smallest.scratch, for example, has nothing — no shell, no libc, no CA certificates. If your application needsgetaddrinfo()(almost every language except Go with CGO_ENABLED=0) or makes HTTPS calls, a scratch image won’t work. Choose the base image that’s the most minimal for your needs, not the smallest in absolute terms.
Image Tags and Versioning #
A tag is a label attached to an image for identification. A tag isn’t just a version — it can be any string, and many teams use tags to mark environments, commit SHAs, or build dates.
Image Reference Anatomy #
flowchart LR
REF["Image Reference"] --> REG["Registry"]
REG --> NS["Namespace / Organization"]
NS --> REPO["Repository"]
REPO --> IMG["Image Name"]
IMG --> TAG["Tag (default: latest)"]
REF2["nginx:1.25.3-alpine"] --> REG2["docker.io (default)"]
REG2 --> NS2["library (default for official images)"]
NS2 --> REPO2["nginx"]
REPO2 --> TAG2["1.25.3-alpine"]The full format:
[REGISTRY[:PORT]/][NAMESPACE/]REPOSITORY[:TAG][@DIGEST]
Concrete examples:
docker.io/library/nginx:1.25.3
ghcr.io/unisbadri/myapi:v2.1.0
123456789012.dkr.ecr.us-east-1.amazonaws.com/prod-api:2026-02-07
myregistry.local:5000/internal/worker@sha256:b5b2b2c507a0944348e0303112d8d08...
Good vs Bad Tagging Strategies #
DON'T use in production:
✗ myapp:latest
- "latest" is the default tag that automatically resolves to the
NEWEST digest. There's no reproducibility guarantee.
- If you push a new image with the "latest" tag today,
an old container that gets restarted could pull the NEW
image you haven't tested yet.
DO use in production:
✓ myapp:v1.4.2
✓ myapp:v1.4.2-alpine
✓ myapp:1.4.2-build.123
✓ myapp:2026-02-07-abc1234 (date + short SHA)
✓ myapp@sha256:b5b2b2c5... (full digest, fully reproducible)
Industry best practice: use immutable SHA256 digests for tags. This guarantees the image you deploy is the exact same image you tested. Semantic version tags (v1.4.2) are for humans, digests are for machines. Many modern CI/CD systems combine both: tags for readability, digests for actual execution.
Tag Mutability — Important for CI/CD #
Tags in a registry are mutable by default. That means anyone with push access can overwrite an existing tag. This is annoying for deployment pipelines.
# After the first push, myapp:v1.0 points to digest A
docker push myapp:v1.0
# Second push (e.g. rebuild) — the v1.0 tag now points to digest B
docker push myapp:v1.0
For production, disable tag mutability in the registry:
- AWS ECR: enable
imageTagMutability: IMMUTABLEper repository. - Docker Hub: doesn’t support immutable tags at the repository level, but you can enforce it via IAM policy.
- Harbor: supports immutable tags at the tag level, with a separate retention rule.
- GHCR: has supported immutable tags since 2023.
Image Size — the Real Production Impact #
Image size isn’t just a cosmetic number. It has a direct impact on four operational areas.
flowchart LR
BIG[1.2 GB image] -->|pull| SLOW1[Pull time: 5-10 minutes]
SLOW1 -->|deploy| SLOW2[Deploy time: slow]
SLOW2 -->|scale| SLOW3[Scaling time: high]
SLOW3 -->|cost| SLOW4[Network + storage cost]
SMALL[15 MB image] -->|pull| FAST1[Pull time: 2-5 seconds]
FAST1 -->|deploy| FAST2[Deploy time: fast]
FAST2 -->|scale| FAST3[Scaling time: low]
FAST3 -->|cost| FAST4[Low network + storage cost]The Four Impacts of Image Size #
| Impact Area | 1 GB Image | 50 MB Image | Difference |
|---|---|---|---|
| Pull time (100 Mbps) | ~80 seconds | ~4 seconds | 20× faster |
| Registry storage | 1 GB | 50 MB | 95% savings |
| Cold start (K8s) | Slow, scheduler waits for the image | Fast, pod ready in seconds | Better UX |
| Cloud egress cost | High | Low | Significant at large traffic |
Techniques for Trimming Image Size #
# ANTI-PATTERN: many layers, no cleanup
FROM node:20
RUN apt-get update
RUN apt-get install -y git
RUN apt-get install -y curl
RUN npm install
COPY . .
CMD ["node", "server.js"]
# CORRECT: combine RUN, clean the cache
FROM node:20-alpine
RUN apk add --no-cache git curl
COPY package*.json ./
RUN npm ci --only=production
COPY . .
USER node
CMD ["node", "server.js"]
Seven main techniques:
- Pick a small base image —
alpine,slim,distroless, orscratchas appropriate. - Combine RUN commands — each
RUNis one layer, and empty layers (for caching) still add to image size. - Clean package manager caches —
apt-get clean && rm -rf /var/lib/apt/lists/*orapk --no-cache. - Use .dockerignore — exclude
node_modules,.git,tests,docs,*.mdfrom the build context. - Multi-stage builds — separate the build environment from the runtime.
- Remove development dependencies — install only
productiondeps (npm ci --only=production,pip install --no-dev). - Strip binaries —
go build -ldflags="-s -w"for Go,--strip-allfor C/C++.
Audit tools: Dive is an interactive tool that shows which layers eat space and which artifacts can be removed. docker history myimage is also useful for seeing the size per Dockerfile instruction.
Image Lifecycle — Build, Tag, Push, Pull, Run #
Images have a consistent lifecycle across the entire Docker ecosystem. Understanding this lifecycle will help you design the right CI/CD pipeline.
flowchart TB
DEV[Developer writes Dockerfile] --> BUILD[docker build -t myapp:1.0 .]
BUILD --> LOCAL_TEST[Local test: docker run]
LOCAL_TEST --> TAG[docker tag myapp:1.0 registry.example.com/myapp:1.0]
TAG --> PUSH[docker push registry.example.com/myapp:1.0]
PUSH --> CI[CI/CD pulls the image]
PUSH --> STAGING[Deploy to staging]
STAGING --> TESTS[Integration tests]
TESTS --> PROD[Deploy to production]
CI --> SCAN[Image scanning]
SCAN --> REPORT[Vulnerability report]
REPORT --> GATE{Scan passed?}
GATE -- Yes --> DEPLOY[Deploy to cluster]
GATE -- No --> FAIL[Build rejected]Core Operations and Their Roles #
| Operation | Command | Who runs it |
|---|---|---|
| Build | docker build | Developer (local) or CI |
| Tag | docker tag | CI |
| Push | docker push | CI after all tests pass |
| Pull | docker pull | Deployment targets, K8s nodes |
| Run | docker run | Local (dev) or runtime (production via Compose/K8s) |
| Inspect | docker inspect | Debugging |
| Save/Load | docker save / docker load | Transfer images between hosts without a registry |
| Export/Import | docker export / docker import | Back up a container filesystem (not an image) |
Image Scanning — a Mandatory Gate in Production Pipelines #
flowchart LR
A[Image pushed] --> B[Trivy scan]
B --> C{Critical CVE?}
C -- Yes --> D[Block deploy]
C -- No --> E[Deploy to staging]
E --> F[Snyk deep scan]
F --> G[Deploy to production]Image scanning analyzes image contents to find CVEs (Common Vulnerabilities and Exposures) in installed packages. Popular tools:
- Trivy — open source, fast, supports image, filesystem, and config scanning.
- Snyk — commercial, GitHub integration, a broad CVE database.
- Docker Scout — built into Docker Desktop, integrates with Docker Hub.
- AWS ECR Image Scanning — automatic on push, integrates with Security Hub.
- GCP Container Scanning — automatic in Artifact Registry.
An important trick: scan images at every layer, not just the final image. Sometimes the vulnerability is in the base image (already patched in a newer version), and the fix is simply to bump the base image. Tools like Trivy automatically check this and suggest safer base images.
Decision Tree — Choosing an Image Strategy #
No single image strategy fits every situation. Use this decision tree as an initial guide.
flowchart TD
A{Application's<br/>programming language?}
A -- Go, Rust, C --> B{Binary needs<br/>dynamic libc?}
B -- No --> C[scratch or distroless/static]
B -- Yes --> D[distroless/base]
A -- Java --> E[JRE-only base image<br/>eclipse-temurin:17-jre-alpine]
A -- Node.js --> F[Multi-stage:<br/>build on node:20<br/>runtime on node:20-alpine]
A -- Python --> G[Multi-stage:<br/>build on python:3.12<br/>runtime on python:3.12-slim]
A -- Static site --> H[Build on node:20<br/>serve via nginx:alpine]
C --> I{Tag<br/>strategy?}
D --> I
E --> I
F --> I
G --> I
H --> I
I -- CI/CD --> J[Tag = semantic version<br/>+ immutable digest]
I -- Internal tool --> K[Tag = commit SHA<br/>+ build number]
I -- Production --> L[Tag = semantic version<br/>+ immutable repository]Summary #
- An image is an immutable blueprint made of filesystem layers. A container is its runtime instance, with a writable layer on top of the image. Images never change after being built; containers can change and will disappear when deleted.
- Layered architecture is the foundation of Docker optimization. Each Dockerfile instruction is one layer. Instruction order determines cache hits — put rarely-changing instructions on top, frequently-changing ones at the bottom.
- Multi-stage builds can trim image size by up to 90% by separating the build environment (with compilers) from the runtime environment (final binary). Highly effective for compiled languages (Go, Rust, Java).
- Tags are mutable — don’t use
latestin production. Use semantic versions (v1.4.2), commit SHAs, or SHA256 digests for reproducibility. Enable tag immutability in the registry for security.- Image size has a direct impact on pull time, deploy time, scaling time, and cloud costs. Aim for the smallest image your application needs, with an appropriate base image (alpine, slim, distroless, or scratch).
- Image scanning is mandatory in CI/CD pipelines. Trivy, Snyk, and Docker Scout detect CVEs in installed packages. Gate deployments on scan results.
- Image lifecycle: build (local/CI) → tag (CI) → push (CI) → pull (deployment target) → run (production). Each stage has its own tooling and automation.
- Base images aren’t just about size — pick the most minimal one for your application’s needs.
scratchis the smallest but has nothing;distrolessis minimal but still has CA certs and timezone data;alpineis complete but lightweight.