CMD, ENTRYPOINT, ARG & Other Instructions
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:
# Exec form: a JSON array, run directly
CMD ["node", "server.js"]
# Shell form: a plain string, run through /bin/sh -c
CMD node server.jsThe 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 &&:
RUN apk add --no-cache curl && mkdir -p /app/uploadsCMD and ENTRYPOINT together
CMDsets the default command. Anything you type after the image name indocker runreplaces it completely.ENTRYPOINTsets 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:
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)}")FROM python:3.15-slim
WORKDIR /app
COPY fare.py .
ENTRYPOINT ["python", "fare.py"]
CMD ["100"]docker build -t fare:1.0 .
docker run --rm fare:1.0
docker run --rm fare:1.0 450 --travel-class 3ac100 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 has | docker run IMAGE runs | docker run IMAGE x y runs |
|---|---|---|
CMD ["a", "b"] only | a b | x y |
ENTRYPOINT ["a"] only | a | a x y |
ENTRYPOINT ["a"] and CMD ["b"] | a b | a x y |
ARG and ENV
Both create variables, but they live at different times:
ARGexists only during the build. You can change it with--build-arg. It is not present in the running container.ENVexists during the build and in every container started from the image.
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}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:
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:
# 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
EOFdocker/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, sodocker stopwaits 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
ARGvalues in the running container. Copy them intoENVif the app needs them. - Secrets in
ENVorARG. They stay readable in the image forever. - Using
ADDfor plain files.COPYis clearer and has no surprises.
Key points
- Write
CMDandENTRYPOINTin exec form (a JSON array with double quotes) so signals reach your app. CMDis a replaceable default;ENTRYPOINTis fixed, andCMDthen supplies default arguments.ARGexists only at build time and can be set with--build-arg;ENValso exists in containers.LABELadds metadata,USERdrops root, andHEALTHCHECKlets Docker reporthealthyorunhealthy.- Prefer
COPYoverADD; start Dockerfiles with# syntax=docker/dockerfile:1to 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
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"]?
In shell form, /bin/sh is process 1 and does not forward the stop signal.
2. An image has ENTRYPOINT ["python", "fare.py"] and CMD ["100"]. What runs for docker run fare 450?
Arguments after the image replace CMD and are appended to the ENTRYPOINT.
3. Which variable is available inside a running container?
ARG disappears after the build; ENV is stored in the image.
4. When should you use ADD instead of COPY?
For downloads, a RUN step with curl or wget gives you more control.
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
