Docker Best Practices: When to Use CMD vs ENTRYPOINT and ADD vs COPY, Measured

Docker best practices, measured: shell-form CMD makes docker stop wait 10 s and drops SIGTERM, exec form stops in 0.27 s. When to use CMD, ADD, COPY.

By Mirac Can Yılmaz 7 min read
Docker Best Practices: When to Use CMD vs ENTRYPOINT and ADD vs COPY, Measured - DevOps yazısının kapak görseli

Two Dockerfiles, one app, one word of difference. CMD python app.py and CMD ["python", "app.py"] look the same, and the container runs the same way until you try to stop it. The first one takes 10.30 seconds to stop and never runs the app's shutdown code. The second stops in 0.27 seconds. The reason is which process ends up as PID 1 inside the container, and whether the signal to stop reaches your app or a shell that ignores it. This post measures that, then does the same for ENTRYPOINT versus CMD arguments and for ADD versus COPY.

What is the difference between CMD and ENTRYPOINT?

Both set what runs when the container starts, and each can be written two ways:

  • Exec form (["python", "app.py"]): Docker runs the program directly. Your program becomes PID 1 and receives signals.
  • Shell form (python app.py): Docker runs /bin/sh -c "python app.py". The shell is PID 1; your program is its child.

ENTRYPOINT is the command that always runs. CMD is the default arguments, and you can replace them on docker run. Used together, ENTRYPOINT ["python", "app.py"] + CMD ["--port", "8080"] means the app always runs and --port 8080 is a default you can override.

What problem does getting this right solve?

  • Slow, unclean shutdowns. Shell form hides your app behind /bin/sh, so SIGTERM never reaches it. The runtime waits the full grace period (10 s with docker stop, 30 s by default in Kubernetes) and then SIGKILLs. Open requests drop, buffers do not flush.
  • Lost shutdown logic. The finally, the connection drain, the "shutting down" log line never run.
  • Arguments that do not do what you think. With only CMD, docker run img somecmd replaces the whole command and your app never starts. With ENTRYPOINT, extra args are appended.
  • ADD surprises. ADD quietly extracts tar archives and can fetch URLs, which bloats images and breaks the build cache in ways COPY never does.

Shell form vs exec form: what you gain and what you pay

Shell form (CMD python app.py) Exec form (CMD ["python","app.py"])
PID 1 inside /bin/sh your app
Receives SIGTERM no (shell does) yes
docker stop time 10.30 s (grace, then SIGKILL) 0.27 s
Runs app shutdown code no yes
Variable expansion ($PORT) yes no (unless you wrap in sh -c)
Zombie reaping / PID 1 duties shell handles some needs --init/tini

How it works: where the signal goes

Diagram: where SIGTERM goes. docker stop sends SIGTERM to PID 1 and waits the grace period. In shell form, PID 1 is /bin/sh, which never forwards the signal to the child python app, so after the grace period docker sends SIGKILL and the stop takes 10.30 seconds with no clean shutdown. In exec form, python is PID 1, its handler logs "got SIGTERM, draining" and the container exits cleanly in 0.27 seconds.

The lab

The app is about eight lines of Python with a SIGTERM handler that logs got SIGTERM, draining and exits. Both images are python:3.13-slim; the only difference is the CMD line. Every number below is from that run.

Terminal: docker stop on the shell-form and exec-form containers. For the shell-form container, cat /proc/1/comm prints sh, so PID 1 is the shell, and docker stop takes 10.30 seconds total, waiting the full 10 second grace before SIGKILL. For the exec-form container, /proc/1/comm prints python, so PID 1 is the app, and docker stop takes 0.27 seconds because the app caught SIGTERM and exited.

Terminal: docker logs for both containers. The shell-form container logs only "app started, pid 7" with no draining line, because SIGTERM never reached the app. The exec-form container logs "app started, pid 1" and then "got SIGTERM, draining", because the shutdown handler ran before exit.

  1. Shell form vs exec form. cat /proc/1/comm inside each container: shell form shows sh, exec form shows python. docker stop on the shell-form container took 10.30 s, and its log has only app started, pid 7, no SIGTERM line: the shell never forwarded the signal, so Docker waited the 10-second grace and sent SIGKILL. The exec-form container stopped in 0.27 s, and its log shows app started, pid 1 followed by got SIGTERM, draining.
  2. The wait is the grace period, not the app. docker stop -t 2 on the shell-form container took 2.29 s: the number is whatever grace you set, because nothing inside is listening.
  3. ENTRYPOINT + CMD arguments. With ENTRYPOINT ["python", "argv.py"] and CMD ["--port", "8080"], docker run img ran argv.py --port 8080; docker run img --port 9090 ran argv.py --port 9090. ENTRYPOINT stayed, CMD was replaced.
  4. CMD alone is fragile. With only CMD ["python", "argv.py", "--port", "8080"], docker run img echo hi printed hi and the app never started: an argument replaced the whole command.
  5. ADD vs COPY. COPY payload.tar.gz /dest/ left one file, the tarball. ADD payload.tar.gz /dest/ left three extracted files and no tarball, and they were owned by uid 501, the id stored in the archive, not a user I set in the image.
  6. The same thing in Kubernetes. I ran both images as pods on kind with the default terminationGracePeriodSeconds: 30. Deleting the shell-form pod took 30.8 s; the exec-form pod was gone in 1.4 s. Kubernetes waits 30 seconds, not Docker's 10, so a shell-form container costs even more on every rollout.
  7. exec rescues shell form. CMD ["sh", "-c", "python app.py"] still left sh at PID 1 and stopped in 10.27 s. CMD ["sh", "-c", "exec python app.py"] put python back at PID 1 and stopped in 0.27 s: the exec replaces the shell with your process, so the signal reaches it. This is the pattern to use when you genuinely need a shell for variable expansion.

Terminal: ls -ln on /dest after COPY versus ADD. COPY left a single file, payload.tar.gz, owned by uid 0. ADD auto-extracted the archive into three files, f1.txt, f2.txt and f3.txt, each owned by uid 501, an id never set in the image.

Where it broke

1. The shell-form container ignored docker stop -t

Setting a shorter grace did not make the shell-form container stop cleanly; it just shortened the wait before SIGKILL. The only fix is to get the app to PID 1.

2. ADD left files owned by a uid I never set

The tar carried uid 501, and ADD preserved it, so /dest ended up owned by a stray numeric id. COPY would have used the build's own ownership.

3. Exec form does not expand variables

CMD ["python", "app.py", "--port", "$PORT"] passed the literal string $PORT, because there is no shell to expand it. The fix is CMD ["sh", "-c", "exec python app.py --port $PORT"]: a shell expands the variable, and exec hands PID 1 back to python so signals still work.

ADD vs COPY: which one, when

COPY ADD
Local files/dirs yes yes
Auto-extracts local tar no yes
Fetches remote URL no yes (not extracted)
Recommended for everything local only local tar you want extracted

Rule: use COPY for local files. Use ADD only to auto-extract a local tarball. For a URL, use RUN curl -fsSL … && … && rm, so you verify and clean up in one layer.

When to use which

  • Default to exec-form CMD so your app is PID 1 and gets signals.
  • Use ENTRYPOINT (exec form) + CMD when the program is fixed and only arguments change.
  • Add --init (or tini) when your process does not reap children.
  • Use shell form only when you genuinely need shell features (variable expansion, pipes), and then run a real init or exec your app so it becomes PID 1.
  • COPY by default; ADD only for local tar extraction.

Summary

  • Shell form puts /bin/sh at PID 1; SIGTERM never reaches your app; docker stop waits 10.30 s, then SIGKILL.
  • Exec form makes your app PID 1; it stops in 0.27 s and runs its shutdown code.
  • ENTRYPOINT fixes the command, CMD gives overridable default args.
  • COPY for local files; ADD only for local tar; RUN curl for URLs.

Next in the series: Prometheus and Grafana on kind, turning a cluster's raw metrics into a dashboard that shows when a service is actually in trouble.

İnsanın en büyük hatalarından biri de doğru zamanı yanlış kişilerle doldurmaktır.
Charles Bukowski