How to Build Docker Images

A Docker image is a read-only template that packages an application with its dependencies and runtime configuration.

Definition

A Docker image consists of immutable filesystem layers plus runtime configuration metadata. The RUN instruction creates filesystem changes by executing commands, while COPY and ADD create them by writing files during a build. A container adds a writable layer without changing the underlying image. Teams build images from Dockerfiles and store them in registries so each environment can run the same versioned artifact.

Summary

  • An image is a stack of read-only layers; a container adds one writable layer on top.
  • A Dockerfile defines the image; RUN creates filesystem layers by executing commands, while COPY and ADD create them by writing files.
  • docker build -t name:tag . turns a Dockerfile plus a build context into an image.
  • The full image tag uses the registry/repository:tag format. Authentication uses docker login; publication uses docker push.
  • BuildKit, the default builder on Linux since Docker Engine 23.0.0, reuses cached layers when instructions and inputs are unchanged.
  • Multi-stage builds cut Docker’s own Go example from 1.11 GB to 28.1 MB.

What Is a Docker Image?

A Docker image is the core artifact that moves through your pipeline. After building it once from source and storing it in a registry, you can run it anywhere as a container. While the image itself remains strictly read-only, starting a container prompts the runtime to add a thin, writable layer on top. When you start a container, the runtime adds a thin writable layer on top of the image’s layers, so every container from that image shares its read-only layers.

Changes a container makes at runtime live only in its writable layer and disappear with the container. Anything you want in every container belongs in the image. The image includes the application binary and its dependencies. It also includes configuration. Scan each version, then promote it through each environment. The container is disposable.

Docker Registry Diagram
A Dockerfile builds static container images that execute as running application instances or push to a central registry for other engines to pull and run.

How Docker Image Layers Work

Each layer in an image captures filesystem additions, modifications, or even deletions. Notably, only three specific Dockerfile instructions create these distinct filesystem layers: RUN executes commands to capture resulting changes, while COPY and ADD write files directly from the build context. Most other instructions, including LABEL, EXPOSE, CMD, ENTRYPOINT, WORKDIR, and USER, record configuration metadata rather than files.

Layer order drives build speed. If a layer changes, the builder rebuilds every layer after it, so put steps that rarely change early in the Dockerfile and steps that change often near the end.

Your base image is a layer you inherit rather than write. Prefer Docker Official Images on Docker Hub over arbitrary community images.

Choosing a Base Image: Alpine, Slim, and Scratch

The base image sets the floor for your final image size. Docker Hub lists these compressed transfer sizes for linux/amd64:

Image Tag Compressed size
Scratch scratch Empty, no layers (0 B)
Alpine alpine:latest (3.23) 3.67 MB
Debian Slim debian:bookworm-slim 26.92 MB
Ubuntu ubuntu:24.04 28.37 MB
Debian (full) debian:stable 47.01 MB

 

Alpine is roughly 7-8x smaller than Debian Slim, and full debian:stable is about 1.75x the size of debian:bookworm-slim. Note that Docker Hub reports compressed download size; docker images shows the larger uncompressed on-disk size.

scratch behaves differently from the others. You can’t pull or run it. Docker also reserves the name scratch, so you can’t tag an image with it. It exists only as a FROM scratch instruction that makes the next Dockerfile command the first filesystem layer. It is typically the final stage of a multi-stage build for a static binary, producing an image with nothing but the binary inside.

Writing a Dockerfile

A Dockerfile is an ordered list of instructions the builder executes top to bottom. Here is an annotated Node.js example:

FROM node:20-alpine          # base image: every build starts from a parent image
WORKDIR /app                # working directory for the instructions that follow
COPY package*.json ./       # copy dependency manifests first (cache optimization)
RUN npm ci --omit=dev       # execute a command; creates a new filesystem layer
COPY . .                   # copy application source into the image
ENV NODE_ENV=production     # set an environment variable
EXPOSE 3000                # document the listening port; does not publish it
ENTRYPOINT ["node"]        # fixed executable
CMD ["server.js"]           # default argument; docker run can override it

EXPOSE trips up beginners: it informs Docker that the container listens on a port at runtime but doesn’t publish it. You publish it with docker run -p. Two of these instructions, CMD and ENTRYPOINT, control what the container executes.

CMD vs ENTRYPOINT

ENTRYPOINT configures the container to run as a fixed executable; CMD provides defaults that are easy to override. When you pass arguments to docker run after the image name, they replace CMD entirely while preserving an exec-form ENTRYPOINT. Use the –entrypoint flag to override ENTRYPOINT itself. When both appear in a Dockerfile:

Configuration What the container runs
Exec-form ENTRYPOINT only exec_entry p1_entry
Exec-form CMD only exec_cmd p1_cmd
Exec-form ENTRYPOINT + exec-form CMD exec_entry p1_entry exec_cmd p1_cmd
Exec-form ENTRYPOINT + shell-form CMD exec_entry p1_entry /bin/sh -c exec_cmd p1_cmd
Shell-form ENTRYPOINT + any CMD /bin/sh -c exec_entry p1_entry (Docker ignores CMD)

The canonical pattern is exec-form ENTRYPOINT for the executable plus exec-form CMD for default arguments. A shell-form ENTRYPOINT silently discards CMD, so Docker recommends the exec form.

Adding a non-root user

Containers run as root (user ID, or UID, 0) by default. Docker’s best-practices guidance is to use USER to switch to a non-root user whenever a service can run without privileges.

RUN groupadd -r appgroup && useradd --no-log-init -r -g appgroup appuser
USER appuser

Assign explicit UID and group ID (GID) values when they matter, since rebuilds assign the next available IDs non-deterministically. Running as non-root is one of the cheapest Application Security (AppSec) wins available in a Dockerfile.

How to Build a Docker Image With Docker Build

You build a docker image by running docker build -t name:tag . from the directory that holds your Dockerfile. That command tells the builder to read the Dockerfile’s instructions, send the files in the trailing path (the build context) to the builder, and write the result as a tagged local image.

docker build -t myapp:1.0 .

The -t flag names and tags the image. Add -f path/to/Dockerfile when you give the file another name or store it elsewhere, and --platform to target a specific architecture such as linux/arm64.

Build context hygiene

The trailing . selects the build context. BuildKit transmits the files the build uses from that context, and COPY and ADD can read only from it. A bloated context slows every build, so add a .dockerignore file in the context root to exclude what the image doesn’t need. Common entries include node_modules, .git, *.log patterns such as npm-debug.log*, plus .env, dist/, and language-specific noise like __pycache__ or Maven’s target directory. The builder never receives files that .dockerignore matches, so COPY cannot read them.

A cross-language starting point:

**/.git
**/.env
**/node_modules
**/__pycache__
**/.venv
**/target
**/Dockerfile*
**/docker-compose*
dist/
npm-debug.log*
LICENSE
README.md

Write one glob per line. ** matches any number of directories. A leading # marks a comment, while a leading ! re-includes a previously excluded path. For example, *.md followed by !README.md excludes all Markdown files except the README.
The operational payoff is twofold: secrets in .env and credentials in .git never reach a layer, and a smaller context means less data shipped to the builder on every build.

Verifying the image locally

docker images (alias docker image ls) lists every local image with its repository, tag, image ID, creation time, and size. The size column reports uncompressed on-disk size, which is larger than the compressed download size a registry page shows. docker run -p 3000:3000 myapp:1.0 starts a container from the image and publishes port 3000 so the app is reachable at localhost:3000. If the container isn’t behaving as expected, docker ps confirms it is running and docker logs shows its output.

docker images
docker run -p 3000:3000 myapp:1.0
docker ps
docker logs <container>

Docker released Engine 23.0.0 in February 2023, making BuildKit the default builder on Linux and docker build an alias for docker buildx build. It caches each layer and reuses it when the instruction and its inputs are unchanged. For RUN, the cache key is the command string itself. For COPY and ADD, it is a checksum derived from the files’ metadata, excluding modification time.

When an instruction invalidates one layer’s cache, BuildKit rebuilds every subsequent instruction. This is why the Node example above copies package*.json and runs npm ci before copying source code: editing application code no longer forces a dependency reinstall, because the dependency layers still hit the cache. Docker’s own guidance is to place expensive steps near the beginning of the Dockerfile and frequently changing steps near the end.

Layer-order checklist

Layer order decides how much of a rebuild is cached after a one-line source-code edit.

Badly ordered:

FROM node:20-alpine
WORKDIR /app
COPY . .                      # source and manifests land in one layer
RUN npm ci --omit=dev         # reruns on every source edit
CMD ["node", "server.js"]

Well ordered:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./         # dependency manifest first
RUN npm ci --omit=dev         # cached until the manifest changes
COPY . .                      # source last
CMD ["node", "server.js"]
Dockerfile Steps cached on rebuild Cache hit rate Dependency install reruns
Badly ordered 2 of 5 (FROM, WORKDIR) 40% Yes
Well ordered 4 of 6 (FROM, WORKDIR, COPY package*.json ./, RUN npm ci) 67% No

Invalidating a layer rebuilds every instruction that follows it, so the copy most likely to change belongs last. COPY and ADD cache keys derive from a checksum of file metadata, excluding modification time.

Reducing Image Size With Multi-Stage Builds

Multi-stage builds utilize an initial stage to compile your application and a second, minimal stage for the final image. By copying across only the finished artifacts you actually need, you successfully leave behind bulky build tools, compilers, and intermediate files.

The single-stage build produces a 1.11 GB image:

FROM golang:1.19
WORKDIR /src
COPY . .
RUN go build -o /bin/app
CMD ["/bin/app"]

The multi-stage version drops to 28.1 MB:

FROM golang:1.19 AS build
WORKDIR /src
COPY . .
RUN go build -o /bin/app

FROM gcr.io/distroless/base-debian11
COPY --from=build /bin/app /bin/app
CMD ["/bin/app"]

Both figures come from Docker’s Go guide, which measured them on the same application.
Work through size optimizations in this order:

  • Pick a smaller base image: Moving from a full distribution to a slim or Alpine variant shrinks the base layer before you change anything else.
  • Restructure the build: Chain commands in one RUN to produce one layer instead of several. Then adopt multi-stage builds to separate the build environment from the runtime image for the biggest reductions.

Skip the --squash flag. It is experimental and depends on the legacy builder, which Docker deprecated in Engine v23.0. BuildKit emits a warning that it removed the flag and recommends multi-stage builds instead.

Tagging and Naming Docker Images

A full image reference has the form registry/repository:tag, for example registry.example.com/team/myapp:1.4.2. Omit the registry and Docker assumes Docker Hub. Omit the tag and Docker applies latest.

That default applies in four places: docker pull, docker tag, docker build, and the FROM instruction. latest is the mutable default tag when you omit a tag, and a publisher can repoint it at any time. Docker’s best-practices documentation warns that because tags are mutable, “you’re not guaranteed to get the same for every build.” For reproducible production builds, pin base images to an immutable digest (alpine@sha256:...).

Pushing Your Image to a Container Registry

Authentication and a tag that follows the registry’s naming convention prepare the image for publication. The push command is:

docker login
docker tag myapp:1.0 username/myapp:1.0
docker push username/myapp:1.0

On Docker Hub the repository name is username/repository; a private Docker registry uses its hostname as the prefix instead. Authenticate CI jobs with access tokens rather than passwords.

For air-gapped environments, docker save -o myapp.tar myapp:1.0 writes the image and all its layers to a tar archive you can move on physical media, then restore with docker load -i myapp.tar.

This workflow builds the image and pushes it to Docker Hub:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      - uses: docker/setup-buildx-action@v4
      - uses: docker/build-push-action@v7
        with:
          push: true
          tags: ${{ vars.DOCKER_USERNAME }}/myapp:latest

Building Multi-Platform Images With Docker Buildx

One command produces an image that runs on both x86 and Arm hosts:

docker buildx build --platform linux/amd64,linux/arm64 -t username/myapp:1.0 --push .

On Docker Engine 29.0+, the containerd image store is the default, so multi-platform builds work out of the box. On older versions, create a docker-container builder with docker buildx create --driver docker-container --bootstrap --use.

That driver does not load multi-platform output into the local engine, which is why the example pushes straight to a registry. BuildKit bundles QEMU emulators for cross-architecture builds. Emulation is slower than native compilation.

Troubleshooting Common Docker Build Failures

Build-context contents, cache keys, file ownership, and platform mismatches cause most Docker build failures. Identifying the structural cause resolves the error faster than retrying the build.

Error Cause Fix
COPY failed: file not found in build context or excluded by .dockerignore: stat app.js: file does not exist The path is outside the build context directory, or a .dockerignore pattern matched it. COPY and ADD can read only files the builder received from the context. Move the file inside the context or run the build from a parent directory that contains it. You can also remove or negate the .dockerignore pattern (e.g. *.md followed by !README.md).
RUN steps report CACHED when you expected them to rerun (for example RUN apt-get update or RUN git clone serving stale content). For RUN, BuildKit’s cache key is the command string itself; it never inspects the files the command produces. Change the command string or pass a cache-busting --build-arg. To bypass the cache entirely, rebuild with docker build --no-cache.
permission denied when the container writes to its working directory after a USER switch. When the builder copies files before the USER instruction, root (UID 0) owns them, and the non-root user cannot write to them. Use COPY --chown=appuser:appgroup . /app to copy with ownership. Assign explicit UID/GID values, since rebuilds otherwise assign the next available IDs non-deterministically.
exec /bin/app: exec format error The builder targeted one architecture, but the host uses another (an linux/amd64 image on an linux/arm64 host, or the reverse). Build for the target with docker build --platform linux/arm64 ., or publish a manifest list with docker buildx build --platform linux/amd64,linux/arm64 --push ..

Common Questions About Docker Images

Docker image failures in CI usually trace back to build-context contents or mutable tags. Cache invalidation also causes failures.

[Implementation note for CMS: render FAQPage JSON-LD for the eight Q&A pairs below in the page head; do not render it as body content.]

What is a Docker image and how is it structured?

A Docker image is an immutable, layered template for creating containers. Filesystem-changing instructions create its layers, and containers add a writable layer at runtime. Containers on the same host share identical read-only layers.

How do I build a Docker image from a Dockerfile?

Run docker build -t name:tag . from the directory containing your Dockerfile. The final argument is the build context. Add --no-cache to force a full rebuild.

Is docker build deprecated?

Docker continues to support docker build. Since Docker Engine 23.0.0, released February 2023, docker build has been an alias for docker buildx build, and BuildKit has been the default builder on Linux. Docker deprecated the legacy builder behind the old command path in Engine v23.0. You can still reach it by setting DOCKER_BUILDKIT=0; the legacy builder remains the default only when building Windows containers. One practical consequence: flags tied to the legacy builder, such as the experimental --squash, no longer work under BuildKit.

How do I tag and push an image to a registry?

Tag the image for the target registry and authenticate with docker login. Then run docker push username/myapp:1.0. Images without an explicit tag default to latest; CI jobs should use access tokens.

How does the build cache work?

BuildKit matches the command string for RUN and file checksums for COPY and ADD. Invalidating one layer rebuilds everything after it, so place stable instructions first.

How do multi-stage builds reduce image size?

They copy only the finished artifact into a minimal final stage with COPY --from=<stage>. Docker omits the build stage and compiler from the final image. It also omits intermediate files.

How do I choose the right base image?

Balance size against runtime requirements. Alpine is the smallest general-purpose distribution base. Debian Slim supports broader distribution packages, while scratch works for fully static binaries. Prefer Docker Official Images or other trusted sources.

Can I create an image from a running container with docker commit?

Yes, docker commit <container> name:tag snapshots a running container’s filesystem into a new image, so you can produce an image without a Dockerfile. The structural problem is that the resulting image carries no build record. Nothing documents which commands produced which layers. The result is not reproducible, and engineers cannot rebuild it from source or audit it. Use docker commit to capture a debugging snapshot of a container’s state. Build from a Dockerfile for anything that ships through a pipeline.

Managing Docker Images with JFrog

A Docker image is a layered, immutable artifact you build from a Dockerfile. You then tag it and push it to a registry. Layer order and base image choice determine how fast those builds run. Multi-stage structure determines how small the images end up.

After you build an image, it needs a registry. Docker Hub’s pull usage limits cap anonymous pulls at 100 per 6 hours and free authenticated pulls at 200. Shared CI runners can concentrate unauthenticated pulls behind one IP address. Configure Artifactory as a remote Docker repository to cache upstream images and keep CI builds available during registry throttling or outages. The JFrog Software Supply Chain Platform manages container images and other package types through that cached registry path.

  • JFrog Artifactory is a universal binary repository and container registry. When you configure it as a remote repository, it caches Docker Hub images so CI runners can reuse upstream images without repeated pulls.
  • The JFrog Container Registry provides Docker registry support built on Artifactory, giving builds a private location for container images.
  • JFrog Xray recursively scans container-image binaries and transitive dependencies at any depth, helping teams identify vulnerable components in stored images.
  • JFrog Curation blocks risky open-source packages at ingestion, before they reach a developer’s environment.

Take a virtual tour to see how Artifactory caches upstream container images and keeps CI builds available, or schedule a demo to map that workflow to your registry architecture.

Release Fast Or Die