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_RUNTIME

Notice 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:

  • FROM creates a layer from an existing image (usually a base image). This layer is often large (ubuntu:22.04 is about 77 MB).
  • RUN, COPY, ADD create new layers from the command’s execution results.
  • CMD, ENTRYPOINT, ENV, EXPOSE, LABEL are 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.mediaType and digest — 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:

FieldFunction
CmdDefault command + arguments
EntrypointThe executable to run
EnvEnvironment variables (KEY=value)
ExposedPortsPorts documented as “accessible”
WorkingDirDefault working directory
UserDefault UID/GID for processes
VolumesDefault mount points
ArchitectureTarget architecture (amd64, arm64, etc.)
OsTarget OS (linux, windows)
Rootfs.diff_idsLayer 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_COPY

What happens behind the scenes:

  1. Stage 1 uses a large image (Go SDK 300+ MB) to compile the code.
  2. Stage 2 starts from a minimal image (distroless, 20 MB).
  3. COPY --from=builder copies only the final binary from stage 1 to stage 2.
  4. 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 ImageTypical SizeUse Case
ubuntu:22.04~77 MBGeneral purpose, lots of tools
node:20~900 MBNode.js development
node:20-alpine~130 MBNode.js production
python:3.12~1 GBPython development
python:3.12-slim~150 MBPython production
gcr.io/distroless/base~20 MBProduction compiled binaries
gcr.io/distroless/static~2 MBStatic Go/Rust binaries
scratch0 MBMinimal 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 needs getaddrinfo() (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: IMMUTABLE per 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 Area1 GB Image50 MB ImageDifference
Pull time (100 Mbps)~80 seconds~4 seconds20× faster
Registry storage1 GB50 MB95% savings
Cold start (K8s)Slow, scheduler waits for the imageFast, pod ready in secondsBetter UX
Cloud egress costHighLowSignificant 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:

  1. Pick a small base imagealpine, slim, distroless, or scratch as appropriate.
  2. Combine RUN commands — each RUN is one layer, and empty layers (for caching) still add to image size.
  3. Clean package manager cachesapt-get clean && rm -rf /var/lib/apt/lists/* or apk --no-cache.
  4. Use .dockerignore — exclude node_modules, .git, tests, docs, *.md from the build context.
  5. Multi-stage builds — separate the build environment from the runtime.
  6. Remove development dependencies — install only production deps (npm ci --only=production, pip install --no-dev).
  7. Strip binariesgo build -ldflags="-s -w" for Go, --strip-all for 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 #

OperationCommandWho runs it
Builddocker buildDeveloper (local) or CI
Tagdocker tagCI
Pushdocker pushCI after all tests pass
Pulldocker pullDeployment targets, K8s nodes
Rundocker runLocal (dev) or runtime (production via Compose/K8s)
Inspectdocker inspectDebugging
Save/Loaddocker save / docker loadTransfer images between hosts without a registry
Export/Importdocker export / docker importBack 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 latest in 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. scratch is the smallest but has nothing; distroless is minimal but still has CA certs and timezone data; alpine is complete but lightweight.

← Previous: Engine & Daemon   Next: Container →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact