Docker Compose command vs entrypoint
ENTRYPOINT is what runs and command supplies its arguments. See all four override combinations in Compose, and why setting entrypoint clears CMD.
Docker Compose command vs entrypoint, in one rule
In Docker Compose, entrypoint: sets the program that runs and command: sets the arguments handed to that program. The container's process is the entrypoint list with the command list appended to the end of it. Every other behaviour on this page follows from that one sentence.
Those two keys map onto two Dockerfile instructions. entrypoint: replaces the image's ENTRYPOINT. command: replaces the image's CMD. They are not independent, and that is where people get stuck: setting entrypoint: also throws away the image's CMD. The Compose specification states it directly. If entrypoint is non-null, Compose ignores any default command from the image.
Read what the image already declares
Before you override anything, look at what the image ships.
docker image inspect --format '{{json .Config.Entrypoint}}' postgres:16
docker image inspect --format '{{json .Config.Cmd}}' postgres:16You get ["docker-entrypoint.sh"] and ["postgres"], so the container runs docker-entrypoint.sh postgres. That script creates the data directory on first boot, reads the POSTGRES_* variables, drops privileges to the postgres user, and finally execs the arguments it was given. Knowing which half you want to change is the whole decision. To pass a flag to the database you replace command:. If you replace entrypoint:, none of that setup runs at all.
The four combinations, shown on a tiny image
Build an image whose only job is to print the argument list it was started with.
FROM alpine:3.20
ENTRYPOINT ["/bin/echo", "ep"]
CMD ["cmd"]docker build -t argdemo .services:
demo:
image: argdemoRun docker compose up after each edit and read the single line it logs.
- Neither key set. The process is
/bin/echo ep cmdand the log showsep cmd. command: ["cmd2"]only. The process is/bin/echo ep cmd2. The entrypoint is untouched and only the arguments changed.entrypoint: ["/bin/echo", "ep2"]only. The process is/bin/echo ep2and the log showsep2. Thecmdfrom the image is gone, and nothing warns you.- Both keys set. The process is
/bin/echo ep2 cmd2. This is the only case where you control the whole argument list.
Why setting entrypoint clears the image CMD
An image's CMD is written as the default argument list for that image's ENTRYPOINT. Replace the entrypoint and those arguments now belong to a program that is no longer running, so Compose drops them instead of building a command line the image author never meant to exist. docker run --entrypoint behaves the same way, so this is Docker behaviour rather than a Compose quirk.
The consequence is concrete. nginx:1.27 declares ENTRYPOINT ["/docker-entrypoint.sh"] and CMD ["nginx", "-g", "daemon off;"]. Set entrypoint: /custom-init.sh and your script starts with an empty argument list. A script that ends in the usual exec "$@" then has nothing to exec, so exec does nothing, the script reaches its last line, and the container exits with code 0 and no error message anywhere. Put the arguments back yourself:
services:
web:
image: nginx:1.27
entrypoint: /custom-init.sh
command: ["nginx", "-g", "daemon off;"]The rule to carry away: any time you set entrypoint:, decide what command: should be in the same edit.
Exec form and shell form, and how Compose differs
A Dockerfile accepts two syntaxes. CMD ["nginx", "-g", "daemon off;"] is exec form: the binary runs directly, with no shell involved. CMD nginx -g "daemon off;" is shell form: Docker rewrites it as /bin/sh -c 'nginx -g "daemon off;"', so a shell runs first and your program becomes its child.
Compose does not copy that rule, and this surprises people. A string in command: is split into arguments and executed directly, with no /bin/sh -c wrapper. The Compose reference is explicit about it: the command field does not run within the SHELL context defined in the image, so if you need shell features you have to invoke a shell yourself.
That is why command: echo "hello $$HOSTNAME" prints the literal text hello $HOSTNAME. No shell ever saw the string, so nothing expanded it. Ask for a shell when you want one:
services:
demo:
image: alpine:3.20
command: /bin/sh -c 'echo "hello $$HOSTNAME"'Signals, PID 1, and a clean docker compose down
docker compose stop and docker compose down send SIGTERM to PID 1 inside each container, wait for stop_grace_period, then send SIGKILL. The default grace period is 10 seconds.
PID 1 is special in Linux. The kernel does not apply the default action of a signal to PID 1, so a process that installs no SIGTERM handler simply ignores SIGTERM when it runs as PID 1. It sits there for the full grace period and is then killed outright, which cuts off any open connection or uncommitted transaction.
A shell in front of your program makes this more likely, because the shell is PID 1 and most shells do not forward signals to a child. Some shells replace themselves with the final command in a -c string, so sometimes your program does reach PID 1 anyway. That depends on the shell and on the exact string, so do not guess. Read it:
docker compose exec -T web cat /proc/1/cmdline | tr '\0' ' '; echoIf PID 1 prints as /bin/sh -c ... rather than your program, there are two fixes. Use exec form in the image, or keep the shell and hand the process over with exec:
services:
web:
image: myapp:1.4
command: /bin/sh -c 'exec myapp --config /etc/myapp.toml'exec replaces the shell process with your program instead of forking a child, so your program inherits PID 1 and receives the signal.
Some programs spawn children and never reap them, which leaves zombie processes, because PID 1 is also the reaper. Compose has a switch for that:
services:
web:
image: myapp:1.4
init: true
stop_grace_period: 30sinit: true runs a small init process as PID 1 that forwards signals to your process and reaps children. stop_grace_period gives a genuinely slow shutdown more room. If your program expects a different signal, stop_signal: SIGQUIT changes what Compose sends. Read what an image already asks for with docker image inspect --format '{{.Config.StopSignal}}' nginx:1.27.
A stack where docker compose down always takes ten seconds per service is telling you that nothing is handling SIGTERM. Fix that before you blame the tooling, and see the difference between docker compose down and stop for what each subcommand removes.
The same exec-versus-shell split turns up in one more place. A healthcheck written as test: ["CMD", "curl", "-f", "http://localhost/"] runs the binary directly, while test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"] runs through a shell so that || means something. Writing Compose healthchecks that fail honestly covers the rest of that field.
Appending a flag to an official image
This is what most readers came for. You want one extra flag on postgres, and you must not disturb the initialisation script.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
command: postgres -c max_connections=200 -c shared_buffers=256MB
volumes:
pgdata:Only command: changed, so docker-entrypoint.sh still runs and still execs what you gave it. Check the result instead of assuming it:
docker compose up -d db
docker compose exec -T db psql -U postgres -c 'show max_connections;'The output should show 200. If it still shows 100, run docker compose config and confirm the command you expect is in the merged output. Compose merges override files by replacing command outright, not by appending to it, so a second file that also sets command: silently wins.
The ${POSTGRES_PASSWORD} above is expanded by Compose on the host from your .env file, before the container exists. Env files and secrets in Compose covers where that value can safely live.
Running a one-off migration with docker compose run
docker compose run builds a new container from the same service definition and replaces the command with whatever you type after the service name. The image's entrypoint still runs, so the container is prepared exactly as the long-running one is.
docker compose run --rm app python manage.py migrate--rmdeletes the container when the command exits. Without it, every run leaves a stopped container behind, visible indocker compose ps -a.- Ports are not published. A
runcontainer ignores the service'sports:unless you add--service-ports, so it cannot collide with the service that is already up. - Dependencies start first. Anything in
depends_oncomes up before your command, and--no-depsskips that. - The container gets a generated name such as
myproject-app-run-9f2c1a, so it never clashes with the service container.
To replace the entrypoint as well, there is a flag for it:
docker compose run --rm --entrypoint /bin/sh app -c 'python manage.py migrate'The resulting argument list is /bin/sh -c 'python manage.py migrate', because the words after the service name are still the command. docker compose exec is the other tool and it works differently: it runs a process inside a container that is already up, and it ignores both entrypoint: and command: entirely. Use run for a task that needs a fresh container, and exec to look inside a live one. The Compose command cheat sheet puts the rest of the subcommands side by side.
Why does my container exit immediately?
Start with the exit code, because it narrows the cause fast.
docker compose ps -a
docker compose logs appExit code 0 and no output. The command ran and finished. The most common cause is an entrypoint: override that took the image's CMD with it, so the entrypoint ran with an empty argument list and had nothing to hand off to.
An error ending in permission denied. The script has no executable bit inside the image, usually because the bit was never set on the file in the repository. Set it at build time with COPY --chmod=0755 entrypoint.sh /entrypoint.sh.
An error ending in no such file or directory for a file you can plainly see in the image. The script has Windows line endings. Its first line then reads #!/bin/sh plus a carriage return byte, so the kernel looks for an interpreter with that byte in its name and finds nothing. Run dos2unix entrypoint.sh, then add * text eol=lf to .gitattributes so it cannot come back.
executable file not found in $PATH. The binary named in command: is not in the image, or you wrote a shell built-in such as cd where only a real program can go.
Getting a shell into an image whose entrypoint fails
When the entrypoint dies before you can inspect anything, replace it:
docker compose run --rm --entrypoint /bin/sh appIf that returns executable file not found in $PATH, the image has no shell at all. Distroless and scratch based images often do not ship one. You can still read the filesystem from outside without starting the entrypoint:
docker create --name probe myapp:1.4
docker export probe | tar -tv | head -40
docker rm probeWhen you need the container to stay up so you can attach to it repeatedly, park it on a process that never exits. Put this in an override file you do not commit:
services:
app:
entrypoint: ["tail", "-f", "/dev/null"]
command: []command: [] is not strictly needed, since setting entrypoint: already cleared the image's CMD, but writing it records the intent for whoever reads the file next. Bring it up and step inside:
docker compose -f compose.yaml -f compose.debug.yaml up -d app
docker compose exec app /bin/shNow run the real entrypoint by hand and watch where it stops. That gives you the error message on your terminal instead of in a container that died half a second ago. If you are still assembling your first stack, a first Compose stack on a VPS covers the file layout everything above assumes.
FAQ
Why does my container exit immediately after docker compose up?
Check docker compose ps -a for the exit code. Exit 0 with no output usually means you set entrypoint: on the service, which also cleared the image's CMD, so the entrypoint ran with an empty argument list and finished. Add the arguments back with command:. An error ending in permission denied means the entrypoint script has no executable bit. An error ending in no such file or directory for a file that exists means the script has Windows line endings, so its shebang line names an interpreter that is not there.
Does setting entrypoint in Compose remove the image's CMD?
Yes. If entrypoint is non-null, Compose ignores any default command declared by the image. That is documented behaviour and it matches docker run --entrypoint. The reason is that an image's CMD is written as arguments for that image's ENTRYPOINT, so once you replace the entrypoint the old arguments no longer belong to anything. Set command: in the same service if the new entrypoint still needs arguments.
Is a string in Compose command run through a shell?
No. Unlike a Dockerfile CMD, a string in Compose command: is split into arguments and executed directly, with no /bin/sh -c wrapper. So $VARIABLE is never expanded by a shell inside the container. Call the shell yourself when you need one, as in command: /bin/sh -c 'echo "hello $$HOSTNAME"'. The doubled $$ escapes the dollar sign so Compose passes it through to the container instead of expanding it on the host.
Why does docker compose down take ten seconds for one container?
Compose sends SIGTERM to PID 1, waits for stop_grace_period (10 seconds by default), then sends SIGKILL. The kernel does not apply default signal actions to PID 1, so a program with no SIGTERM handler ignores the signal and always waits out the full period. Find out what PID 1 really is with docker compose exec -T app cat /proc/1/cmdline | tr '\0' ' '. If it is a shell, switch the image to exec form or write exec inside the shell string. If the process spawns children it never reaps, set init: true on the service.