Dash0 Raises $110M Series B at $1B Valuation

  • 10 min read

How to Run Multiple Commands in Docker Compose

You put two commands in your command field, ran docker compose up, and instead of running both, the container printed your second command back at you as text. Like this:

yaml
1234
services:
app:
image: alpine
command: echo hello && echo world
text
12
app-1 | hello && echo world
app-1 exited with code 0

The && was supposed to chain two commands. Instead it became a literal argument to echo. Compose runs the command field directly, without a shell, so operators like &&, ;, |, and $VAR expansion mean nothing to it. This article gives you the fix, explains why the bare string doesn't do what you'd expect, and shows when to override the entrypoint instead.

The fix: wrap your commands in sh -c

Run the commands inside a shell explicitly. Hand the whole sequence to sh -c, or bash -c if the image ships bash:

yaml
1234
services:
app:
image: alpine
command: sh -c "echo hello && echo world"

Now sh is the program Compose runs, -c tells it to read commands from the string that follows, and the shell is what interprets &&:

text
123
app-1 | hello
app-1 | world
app-1 exited with code 0

That covers most cases. The why is worth a minute, though, because it shapes how you write anything more involved, like a migration that has to finish before a server boots.

Why a bare command string skips the shell

If you've written a Dockerfile, you know CMD has two forms. The shell form, CMD echo hello && echo world, gets wrapped in /bin/sh -c for you, so chaining just works. The exec form, CMD ["echo", "hello"], runs the binary directly with no shell.

Compose doesn't copy that behavior, and this is the part that trips everyone up. The official docs spell it out: the command field doesn't run inside the image's shell. A bare string gets split into separate arguments and run directly, exactly like the exec form. Here's what that split actually produces:

text
12
echo hello && echo world
-> ["echo", "hello", "&&", "echo", "world"]

So echo gets four arguments and dutifully prints all of them. The shell operator was never an operator. That's the whole bug. The same string that works as a Dockerfile CMD behaves differently as a Compose command, and there's no shell-form shortcut to fall back on. Want shell features? Ask for a shell.

Compose accepts two forms, the string and the list, and both are exec-style:

yaml
12345
# String form, split on whitespace
command: python -m http.server 8000
# List form, explicit and quoting-safe
command: ["python", "-m", "http.server", "8000"]

For a single command with no operators, take your pick. Reach for the list form when an argument contains spaces or characters that would otherwise split in the wrong place. The moment you need &&, ;, a pipe, or variable expansion, you need sh -c.

Chaining several commands cleanly

The operators behave the way they do in any terminal, and the one you pick changes what happens on failure:

  • && runs the next command only if the previous one exited zero. Use it for dependent steps where a failure should stop everything, like running a migration before starting a server.
  • ; runs the next command no matter what the previous one returned.
  • || runs the next command only if the previous one failed, which is handy for fallbacks.

A long chain on one line turns into noise fast. YAML's literal block scalar (|) lets you put each command on its own line, which is much easier to live with:

yaml
123456789101112
services:
web:
image: python:3.13-slim
command:
- sh
- -c
- |
set -e
echo "Running migrations"
python manage.py migrate
echo "Starting server"
exec python manage.py runserver 0.0.0.0:8000

Two lines there are quietly important. set -e makes the shell bail the moment any command fails. You want that, because inside a | block the newlines act like ;, so without it a failed migration sails right through to a server starting against a broken database. The exec on the last line matters too, and the next section gets into why.

Overriding the entrypoint for complex cases

Setting command only replaces the image's default arguments, its CMD. It leaves the image's ENTRYPOINT alone. Plenty of popular images, Postgres and MySQL included, ship an ENTRYPOINT script that does setup and then runs whatever you passed as the command. Set command on one of those, and your command becomes an argument handed to that script, not the thing that actually runs.

To take over startup completely, override entrypoint:

yaml
12345
services:
app:
image: myorg/app:latest # image defines its own ENTRYPOINT
entrypoint: ["sh", "-c"]
command: ["echo 'custom startup' && exec /app/run.sh --debug"]

The process Compose launches here is sh -c "echo 'custom startup' && exec /app/run.sh --debug". The entrypoint is the shell, the command is the script it runs. That exec is there for the same reason it was in the multiline block: it replaces the shell with run.sh, so your long-running process becomes PID 1 and receives signals directly instead of sitting behind the shell. The pitfalls section below digs into why that matters. Setting entrypoint to a non-null value also makes Compose ignore the image's CMD, so you start from a clean slate.

One more handy trick: an empty value wipes the image's entrypoint entirely.

yaml
1
entrypoint: [] # or entrypoint: ""

That's the escape hatch for when an image's built-in entrypoint keeps getting in your way and you just want to run something raw.

When startup logic grows past a handful of lines, stop cramming it into YAML. Write a real shell script, copy it into the image in your Dockerfile, and point entrypoint at it. Your Compose file stays readable, and the logic lives somewhere you can test and version it properly.

Common pitfalls

The literal-argument trap up top is the one people hit first. Two others do more damage in production.

The first is signal handling. Wrap your app in sh -c "setup && myapp" and the shell becomes PID 1 inside the container. Most shells don't pass signals down to their child processes, so when you run docker compose stop, the SIGTERM lands on the shell and your application never hears it. Compose waits out the grace period, 10 seconds by default, then hard-kills the container with SIGKILL. No chance to flush buffers or finish in-flight requests. The fix is that exec from earlier: sh -c "setup && exec myapp" replaces the shell with your app, so your app is PID 1 and gets signals directly. That one keyword is the difference between a clean shutdown and a forced kill on every deploy.

The second is silent failures in multiline blocks. A | block without set -e cheerfully marches past a command that errored, because each line runs on its own. You think you chained four steps. You actually ran four commands that don't care whether the one before them worked. Add set -e at the top, or chain with && so a failure stops the sequence.

Final thoughts

The command field runs your program directly, with no shell in between. Anything that needs a shell, like chaining with && or expanding a variable, goes through sh -c. Past a couple of steps, override the entrypoint or move the logic into a script, and exec your long-running process so signals reach it.

Getting the container to start is one thing. Knowing what it does after it starts is harder to read off a command line. A container that exits 137 or loops on restart rarely explains itself in docker compose logs. Dash0's infrastructure monitoring tracks container health and resource usage next to real-time logs, so you can tell whether that chained startup finished or got SIGKILLed partway through. If you're still fighting with container output, the guides on Docker Compose logs and mastering Docker logs are good next reads. Start a free trial to watch your containers start up and shut down from one place. No credit card required.