CMD, ENTRYPOINT, ARG & Other Instructions

Docker & DevOps · Lesson 9 of 26 8 min read Updated

The previous lesson used the seven instructions you need for most images. Real Dockerfiles use a few more, and some of them are easy to get subtly wrong. CMD and ENTRYPOINT look alike but behave differently. ARG and ENV both set variables, but at different times. A missing USER line runs your app as root.

This lesson covers these instructions one by one, with a small Python tool and the ferry API from Writing a Dockerfile as examples.

Exec form and shell form

CMD, ENTRYPOINT and RUN can be written in two forms:

Dockerfile
# Exec form: a JSON array, run directly
CMD ["node", "server.js"]

# Shell form: a plain string, run through /bin/sh -c
CMD node server.js

The shell form starts /bin/sh, which then starts Node.js. That has a hidden cost: the shell becomes process 1 in the container, and when you run docker stop, the SIGTERM signal goes to the shell, which does not pass it on to Node.js. Your app never gets the chance to shut down cleanly, and Docker kills it after 10 seconds.

Use the exec form for CMD and ENTRYPOINT. Note that it needs double quotes; single quotes are not valid JSON, and Docker would silently treat the line as shell form. For RUN, the shell form is normal and convenient, because you often want shell features such as &&:

Dockerfile
RUN apk add --no-cache curl && mkdir -p /app/uploads

CMD and ENTRYPOINT together

  • CMD sets the default command. Anything you type after the image name in docker run replaces it completely.
  • ENTRYPOINT sets the fixed program that always runs. Text after the image name is added to it as extra arguments.

When you use both, CMD provides default arguments for the ENTRYPOINT. This is perfect for images that wrap one command-line tool. Here is a tiny fare calculator, fare.py:

Python
import argparse

parser = argparse.ArgumentParser(prog="fare", description="Estimate a train fare in rupees.")
parser.add_argument("km", type=int, help="distance in kilometres")
parser.add_argument("--travel-class", default="sleeper", choices=["sleeper", "3ac", "2ac"])
args = parser.parse_args()

rate = {"sleeper": 0.5, "3ac": 1.3, "2ac": 1.9}[args.travel_class]
print(f"{args.km} km in {args.travel_class}: ₹{round(args.km * rate)}")
Dockerfile
FROM python:3.15-slim
WORKDIR /app
COPY fare.py .
ENTRYPOINT ["python", "fare.py"]
CMD ["100"]
Terminal
docker build -t fare:1.0 .
docker run --rm fare:1.0
docker run --rm fare:1.0 450 --travel-class 3ac
Output
100 km in sleeper: ₹50
450 km in 3ac: ₹585

The first run used the default argument from CMD. The second replaced CMD with 450 --travel-class 3ac, but the ENTRYPOINT stayed. The image now behaves like a command. To replace the entrypoint itself, for example to get a shell for debugging, use docker run --rm -it --entrypoint sh fare:1.0.

Dockerfile hasdocker run IMAGE runsdocker run IMAGE x y runs
CMD ["a", "b"] onlya bx y
ENTRYPOINT ["a"] onlyaa x y
ENTRYPOINT ["a"] and CMD ["b"]a ba x y

ARG and ENV

Both create variables, but they live at different times:

  • ARG exists only during the build. You can change it with --build-arg. It is not present in the running container.
  • ENV exists during the build and in every container started from the image.
Dockerfile
ARG PYTHON_VERSION=3.15
FROM python:${PYTHON_VERSION}-slim

ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} \
    PYTHONUNBUFFERED=1
LABEL org.opencontainers.image.version=${APP_VERSION}
Terminal
docker build --build-arg PYTHON_VERSION=3.14 --build-arg APP_VERSION=2.3.0 -t fare:2.3.0 .

An ARG placed before FROM can only be used in FROM lines. Declare it again after FROM if you need it later. Here APP_VERSION is copied into an ENV, so the running app can read it. PYTHONUNBUFFERED=1 makes Python print logs immediately instead of buffering them, which is helpful with docker logs.

LABEL, USER and HEALTHCHECK

LABEL adds key-value information to the image. The org.opencontainers.image.* keys are a common standard: source links the image to its code repository (GitHub uses it to connect a package to a repository), and version and description explain what it is.

USER sets which user runs the following RUN instructions and the container itself. By default that is root. Official images often include an unprivileged user, such as node in the Node.js images. Image Security covers this in depth.

HEALTHCHECK tells Docker how to test whether the app inside is actually working, not just running. Here it is for the ferry API:

Dockerfile
FROM node:26-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD ["node", "server.js"]

Every 30 seconds, Docker runs wget inside the container. If it fails three times in a row, the container is marked unhealthy. docker ps then shows the health next to the status, for example Up 2 minutes (healthy). The --start-period gives the app 10 seconds to start before failures count. Alpine images include BusyBox wget; Debian -slim images have neither wget nor curl, so there you would install one or write the check in the app's own language.

COPY --chown=node:node makes the copied files belong to the node user instead of root.

COPY or ADD?

ADD does everything COPY does, plus two extra tricks: it unpacks local .tar archives automatically and it can download from URLs. Those surprises are why the rule is simple: use COPY unless you specifically need archive extraction. For downloads, use RUN with curl or wget, so you can check the file and delete it in the same step.

The syntax directive and heredocs

A Dockerfile may start with a parser directive that tells BuildKit which version of the Dockerfile language to use:

Dockerfile
# syntax=docker/dockerfile:1
FROM alpine:3.22
RUN <<EOF
apk add --no-cache tzdata
cp /usr/share/zoneinfo/Asia/Kolkata /etc/localtime
echo "Asia/Kolkata" > /etc/timezone
EOF

docker/dockerfile:1 means "the newest stable 1.x version", so you get new features without waiting for a Docker update. The example also shows a heredoc: several shell lines in one RUN, without long chains of && and backslashes.

Common mistakes

  • Shell-form CMD. Signals do not reach your app, so docker stop waits 10 seconds and then kills it. Use the exec form.
  • Single quotes in exec form. CMD ['node', 'server.js'] is not valid JSON, so Docker treats it as a shell command and it fails. Use double quotes.
  • Expecting ARG values in the running container. Copy them into ENV if the app needs them.
  • Secrets in ENV or ARG. They stay readable in the image forever.
  • Using ADD for plain files. COPY is clearer and has no surprises.

Key points

  • Write CMD and ENTRYPOINT in exec form (a JSON array with double quotes) so signals reach your app.
  • CMD is a replaceable default; ENTRYPOINT is fixed, and CMD then supplies default arguments.
  • ARG exists only at build time and can be set with --build-arg; ENV also exists in containers.
  • LABEL adds metadata, USER drops root, and HEALTHCHECK lets Docker report healthy or unhealthy.
  • Prefer COPY over ADD; start Dockerfiles with # syntax=docker/dockerfile:1 to get the latest stable syntax.

Exercise

Write a Dockerfile for an image called pincode that wraps a script lookup.py. The script takes a PIN code as its argument. Running the image with no arguments should look up 124001 (Rohtak). The Python version should be a build argument with the default 3.15, the image should carry an org.opencontainers.image.description label, and the script should not run as root.

Show solution
Dockerfile
ARG PYTHON_VERSION=3.15
FROM python:${PYTHON_VERSION}-slim
LABEL org.opencontainers.image.description="Look up an Indian PIN code"
WORKDIR /app
COPY lookup.py .
RUN useradd --create-home appuser
USER appuser
ENTRYPOINT ["python", "lookup.py"]
CMD ["124001"]

docker run --rm pincode runs python lookup.py 124001, and docker run --rm pincode 302001 runs it for Jaipur. useradd creates an ordinary user, and USER appuser makes the container run as that user.

Quick Quiz

1. Why should CMD use the exec form ["node", "server.js"]?

2. An image has ENTRYPOINT ["python", "fare.py"] and CMD ["100"]. What runs for docker run fare 450?

3. Which variable is available inside a running container?

4. When should you use ADD instead of COPY?

Learn Docker & DevOps with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call