Contents

Dockerfile Instructions Tutorial: Learn FROM, RUN, COPY, CMD, and ENTRYPOINT

The previous section used a small example to create your first Dockerfile. This section explains each instruction and uses small experiments to compare the pairs that are easiest to confuse.

Basic rules

  • Instructions are not case-sensitive, but convention uses uppercase to distinguish them from arguments.
  • Instructions run from top to bottom. The first instruction (except ARG and comments) must be FROM.
  • Lines starting with # are comments.
  • If a line is too long, you can continue it with \.
Dockerfile instructions take effect either at build time or runtime, and ENTRYPOINT and CMD can be combined
Instructions that take effect at build time (docker build) and runtime (docker run), and the ENTRYPOINT + CMD combination

FROM: base image

FROM python:3.14-slim
FROM --platform=linux/amd64 node:24-alpine
FROM scratch                     # Completely empty image for statically compiled programs
FROM golang:1.27 AS builder      # Names a stage in a multi-stage build

RUN: run commands at build time

# Shell form: runs through /bin/sh -c and supports && and pipes
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

# Exec form: does not use a shell
RUN ["pip", "install", "--no-cache-dir", "flask"]

Each RUN creates a layer. Put apt-get update and apt-get install in the same RUN; otherwise, the cached update layer may become stale and install an older package version. Cleaning up the package lists at the end (rm -rf /var/lib/apt/lists/*) makes the image smaller. See Build cache and optimization for how much difference it makes.

COPY and ADD: copy files

COPY package.json package-lock.json ./
COPY src/ ./src/
COPY --chown=app:app . .
COPY --from=builder /app/dist ./dist     # Copies from another build stage

ADD is similar to COPY, but behaves differently with tar archives. Experiment: prepare a files.tar.gz containing files/a.txt and files/b.txt, then copy it with each instruction:

FROM alpine:3.23
COPY files.tar.gz /copy/
ADD files.tar.gz /add/
CMD ["sh", "-c", "echo '--- COPY:'; ls -R /copy; echo '--- ADD:'; ls -R /add"]
$ docker build -q -t add-demo .
sha256:2466246e51b76cd547fa913fce223fccc227ae19b9e53006b9ce203e1afc8ec4

$ docker run --rm add-demo
--- COPY:
/copy:
files.tar.gz
--- ADD:
/add:
files

/add/files:
a.txt
b.txt
  • COPY copied files.tar.gz as-is.
  • ADD automatically extracted the archive, so /add directly contains files/a.txt and files/b.txt.

ADD can also take a URL as its source (and download it automatically). These automatic behaviors can be surprising, so Docker recommends using COPY in general and using ADD only when you need archive extraction.

WORKDIR: working directory

WORKDIR /app

Subsequent instructions such as RUN, COPY, and CMD run in this directory, which is created automatically if it does not exist. Use WORKDIR instead of writing RUN cd /app: each RUN uses a separate shell, so cd does not carry over to the next line.

ENV and ARG: variables

ARGENV
When it takes effectAt build time onlyAt build time and when the container runs
How to set itdocker build --build-arg NAME=valueIn the Dockerfile or with docker run -e
Visible in the container?NoYes

Experiment:

FROM alpine:3.23
ARG APP_VERSION=dev
ENV APP_ENV=production
RUN echo "building version $APP_VERSION" > /build-info.txt
CMD ["sh", "-c", "cat /build-info.txt; echo APP_ENV=$APP_ENV; echo APP_VERSION=${APP_VERSION:-<not set>}"]
$ docker build -q --build-arg APP_VERSION=1.2.0 -t args-demo .
sha256:3537c3e70baef148c3481f97bf325963b4809a15b806a2e38fed7e8eb5f50619

$ docker run --rm args-demo
building version 1.2.0
APP_ENV=production
APP_VERSION=<not set>

$ docker history --no-trunc --format "{{.CreatedBy}}" args-demo | head -n 3
CMD ["sh" "-c" "cat /build-info.txt; echo APP_ENV=$APP_ENV; echo APP_VERSION=${APP_VERSION:-<not set>}"]
RUN |1 APP_VERSION=1.2.0 /bin/sh -c echo "building version $APP_VERSION" > /build-info.txt # buildkit
ENV APP_ENV=production
  • The build-time value APP_VERSION=1.2.0 was used by RUN (written to /build-info.txt), but it is no longer visible when the container runs (<not set>).
  • The APP_ENV set with ENV is visible in the container.
  • Notice the second line of docker history: RUN |1 APP_VERSION=1.2.0 .... The ARG value is recorded in the image history, where anyone with the image can see it.

So never use ARG or ENV to pass passwords or API keys. To use secrets at build time, use RUN --mount=type=secret. See Environment variables and .env files.

An ARG declared before FROM can only be used in FROM. To use it after FROM, declare it again with ARG name.

EXPOSE: document ports

EXPOSE 3000
EXPOSE 53/udp

EXPOSE is documentation only. It tells users which port the container uses; it does not actually publish the port. You still need docker run -p to publish it. Its only effect is when used with -P, which publishes every EXPOSE port on a random host port (the nginx image has EXPOSE 80):

$ docker run --rm -d --name exp -P nginx:1.30
857560427b1218d038314394f03cc0eb8edf950062304efaf2f5f3dc995f6ef6

$ docker port exp
80/tcp -> 0.0.0.0:32768
80/tcp -> [::]:32768

$ docker rm -f exp
exp

USER: run as a non-root user

By default, the container runs as root. Running as a regular user limits the impact if the program is compromised:

FROM alpine:3.23
RUN addgroup -S app && adduser -S -G app app
USER app
CMD ["sh", "-c", "whoami; id"]
$ docker build -q -t user-demo .
sha256:bdcaab4c6133a5446cdaaa1ca3353e01a690dc9fd0647458543abcec0d6041f1

$ docker run --rm user-demo
app
uid=100(app) gid=101(app) groups=101(app),101(app)

$ docker run --rm alpine:3.23 whoami
root

Many official images include a regular user already. For example, the node image has a node user, so you can use USER node directly.

VOLUME: declare a data directory

VOLUME /var/lib/data

This declares that the directory should hold persistent data. If you do not mount it at runtime, Docker automatically creates an anonymous Volume (see the Volume section for a hands-on example). In practice, you usually specify a mount directly with docker run -v or in Compose, so your Dockerfile may not need a VOLUME instruction.

HEALTHCHECK: health checks

Docker periodically runs the health check command. An exit code of 0 means healthy; a nonzero code means unhealthy:

FROM nginx:1.30-alpine
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
  CMD wget -q --spider http://localhost/ || exit 1

Experiment: wait a few seconds after startup, then deliberately delete the home page to make nginx return an error:

$ docker run -d --name health health-demo
52bfa4cce8024630797ba6f33ba99dea44f80399fbadd617b2d04a8ac7f8e3b1

$ docker ps --filter name=health
CONTAINER ID   IMAGE         COMMAND                  CREATED        STATUS                                     PORTS     NAMES
52bfa4cce802   health-demo   "/docker-entrypoint.…"   1 second ago   Up Less than a second (health: starting)   80/tcp    health

$ docker ps --filter name=health
CONTAINER ID   IMAGE         COMMAND                  CREATED         STATUS                   PORTS     NAMES
52bfa4cce802   health-demo   "/docker-entrypoint.…"   9 seconds ago   Up 8 seconds (healthy)   80/tcp    health

$ docker exec health rm /usr/share/nginx/html/index.html

$ docker ps --filter name=health
CONTAINER ID   IMAGE         COMMAND                  CREATED          STATUS                      PORTS     NAMES
52bfa4cce802   health-demo   "/docker-entrypoint.…"   29 seconds ago   Up 28 seconds (unhealthy)   80/tcp    health

$ docker inspect --format '{{range .State.Health.Log}}{{.ExitCode}} {{.Output}}{{println}}{{end}}' health
0
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden

$ docker rm -f health
health

The status changes from health: starting (just started) to healthy (check passed), then after deleting the home page and failing three times (--retries=3) to unhealthy. .State.Health.Log records each check's exit code and output.

  • The image needs a tool such as curl or wget for this to work (Alpine includes wget).
  • Compose can wait until a service is healthy before starting services that depend on it. See compose.yaml syntax.

CMD and ENTRYPOINT: startup commands

Both determine what runs when the container starts. The difference is whether they can be overridden and how they are combined.

CMD: can be replaced entirely

FROM alpine:3.23
CMD ["echo", "hello from CMD"]
$ docker build -q -t cmd-demo .
sha256:45b2af259ce828e227961bcc620b973758a1f757d948a7e23e056781afba324d

$ docker run --rm cmd-demo
hello from CMD

$ docker run --rm cmd-demo echo overridden
overridden

A command written after the image name in docker run replaces CMD entirely.

ENTRYPOINT: fixed main program

FROM alpine:3.23
ENTRYPOINT ["echo", "ENTRYPOINT says:"]
CMD ["default argument"]
$ docker build -q -t ep-demo .
sha256:32414489000b71c255d808cfd4cda73764b526525229dece3a8f1fd5487fc6d1

$ docker run --rm ep-demo
ENTRYPOINT says: default argument

$ docker run --rm ep-demo my argument
ENTRYPOINT says: my argument

$ docker run --rm --entrypoint date ep-demo
Mon Oct 12 23:59:17 UTC 2026
  • When both ENTRYPOINT and CMD are present, Docker runs ENTRYPOINT + CMD.
  • Text after docker run replaces only CMD and becomes arguments to ENTRYPOINT.
  • To replace ENTRYPOINT itself, use --entrypoint.
CMDENTRYPOINT
PurposeDefault command or default argumentsThe container's main executable
docker run image xxxxxx replaces CMDxxx is appended to ENTRYPOINT
How to overrideWrite a command after the image name--entrypoint

How to choose:

  • Regular applications: write only CMD, so users can run docker run -it myapp sh to open a shell.
  • To use a container as “a command” (for example, a CLI tool): use ENTRYPOINT to specify the program and put default arguments in CMD.

Exec form and shell form

CMD ["node", "server.js"]    # Exec form (JSON array)
CMD node server.js           # Shell form

Shell form actually runs /bin/sh -c "node server.js", so it supports environment variable expansion and shell syntax such as &&. Exec form runs the program directly without a shell. Exec form is generally recommended because its behavior is clearest.

PID 1 and why docker stop can take 10 seconds

Container lifecycle mentioned that docker stop sometimes waits the full 10 seconds. The example below uses the same Node.js program (server.js from the previous section, which does not handle SIGTERM) to compare four CMD forms. Use docker top to inspect the processes in each container, then time docker stop:

$ docker top shell-npm
PID                 USER                TIME                COMMAND
4155                root                0:00                npm start
4182                root                0:00                {MainThread} node server.js

$ time docker stop shell-npm
shell-npm
real	0m 0.66s
user	0m 0.00s
sys	0m 0.00s

$ docker top exec-npm
PID                 USER                TIME                COMMAND
4318                root                0:00                npm start
4347                root                0:00                {MainThread} node server.js

$ time docker stop exec-npm
exec-npm
real	0m 0.67s
user	0m 0.00s
sys	0m 0.00s

$ docker top shell-node
PID                 USER                TIME                COMMAND
4488                root                0:00                {MainThread} node server.js

$ time docker stop shell-node
shell-node
real	0m 10.14s
user	0m 0.01s
sys	0m 0.01s

$ docker top exec-node
PID                 USER                TIME                COMMAND
4660                root                0:00                {MainThread} node server.js

$ time docker stop exec-node
exec-node
real	0m 10.14s
user	0m 0.01s
sys	0m 0.00s
CMD formPID 1 in the containerdocker stop
CMD npm startnpm0.66 seconds
CMD ["npm", "start"]npm0.67 seconds
CMD node server.jsnode10.14 seconds
CMD ["node", "server.js"]node10.14 seconds

The key is not shell form versus exec form, but which process is PID 1:

  • Linux treats PID 1 specially: if PID 1 does not handle SIGTERM itself, the signal is ignored. When node runs directly as PID 1 and the program does not handle SIGTERM, docker stop has to wait 10 seconds before force-stopping it with SIGKILL.
  • When npm starts node, npm is PID 1 and forwards SIGTERM to node. Node is not PID 1, so it exits on SIGTERM according to its default behavior.
  • The shell form in the first row does not add another sh process because Alpine's sh replaces itself with npm when there is only one command.

Choose one of these solutions:

  1. Handle SIGTERM in your program and shut down the server gracefully (best practice; see Node.js applications).
  2. Add --init at runtime (or set init: true in Compose) so a small init process runs as PID 1 and forwards signals.

Other instructions

InstructionPurpose
LABELAdd metadata, for example LABEL org.opencontainers.image.source="https://github.com/..."
SHELLChange the shell used by shell form, such as PowerShell in Windows containers
STOPSIGNALChange the signal sent by docker stop (the nginx image sets this to SIGQUIT; see docker history in Images)
ONBUILDAn instruction that runs only when another image uses this image as its base (rarely used)

Complete example: Python website

FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd --system app
USER app

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

PYTHONUNBUFFERED=1 makes Python write output immediately, so you can see it in docker logs right away. See Deploy a Python application with Docker for the build and run process for this Dockerfile.

Recap

  • RUN, COPY, and ARG take effect at build time; CMD, ENTRYPOINT, USER, and ENV affect runtime.
  • The xxx in docker run image xxx replaces CMD or is appended to ENTRYPOINT.
  • Use exec form (JSON arrays) for CMD and ENTRYPOINT.
  • Do not pass passwords with ARG or ENV.

Exercises

Exercise 1: An image sets ENTRYPOINT ["ping"] and CMD ["-c", "3", "localhost"]. What does docker run image -c 1 example.com actually run?

Answer

ping -c 1 example.com. The runtime arguments replace CMD and are then appended to ENTRYPOINT.

Exercise 2: Why does ENV NODE_ENV production trigger a warning from docker build --check?

Answer

This is the old syntax. Write ENV NODE_ENV=production instead. The warning is named LegacyKeyValueFormat.

FAQ

Should I use COPY or ADD?

Prefer COPY in general. ADD automatically extracts tar files and can download URLs, which makes its behavior less obvious. Use it only when you need those features.

Should I use CMD or ENTRYPOINT?

Use CMD for most applications so users can override it easily. Use ENTRYPOINT when the image acts like a command-line tool that always runs the same program and only changes its arguments.

How can I check whether my Dockerfile has problems?

Run docker build --check . to see warnings such as JSONArgsRecommended and LegacyKeyValueFormat. See [docker init](/docker/docker-init/).

Updated October 14, 2026