Docker Compose command with multiple commands and multiple lines - sh -c, the list form, $$ and depends_on (tested on Compose v2.38)
TL;DR
- Wrap the commands in a shell:
command: sh -c "echo one && echo two". Compose does not run a shell for you. - Without
sh -c, Compose silently drops everything after&&,;or|.command: echo one && echo twoprinted onlyone, anddocker compose configshows the parsed command as[echo, one]. No warning, exit code 0. - For many lines, use the list form with a block:
command: ["sh", "-c", ...]written as a YAML list ending in- |, one command per line, starting withset -eso the first failure stops the rest. - Write
$$VARinside the command for variables the container should expand. A single$VARis replaced by Compose from your shell before the container starts - mine printed[]and a warning. - A one-shot setup step belongs in its own service with
depends_on: condition: service_completed_successfully. In my runs that madedocker compose up -dwait about 2.5 to 3 seconds for a 2 second migration, against about 440 ms without the wait. - Every output below is from Docker 28.0.4 and Docker Compose v2.38.2 on Ubuntu 24.04, captured on 2026-10-14.
The first version of this post (2023) gave three framework examples - Flask with Celery, React, Django - and they all used sh -c. What it did not explain is why you need sh -c, or what happens when you leave it out. When I reproduced the examples for this rewrite, the "leave it out" case turned out to be the interesting one: Compose does not fail, it just quietly runs half of your command. So this version starts there.
Table of Content
- The forms at a glance
- What happens without sh -c
- sh -c with && or ;
- Multiple lines - the list form with a block
- Variables - $VAR versus $$VAR
- When one command fails - semicolon versus &&
- A setup step before the app - a one-shot service and depends on
- Running a command after the container starts - the post start hook
- Real examples - Django, Flask with Celery, Node
- What broke when I ran this
- Conclusion
1. The forms at a glance
How you write command: | What runs | && ; | $VAR work? | Use it for |
|---|---|---|---|
echo one && echo two | echo one only - the rest is dropped | no | never with operators |
sh -c "echo one && echo two" | a shell runs the whole string | yes | one-liners |
["sh", "-c", "echo one; echo two"] | same, quotes are explicit | yes | one-liners with tricky quoting |
list form ending in - | block | a shell runs every line | yes | three or more steps |
a separate service + depends_on | each step in its own container | n/a | migrations, seeding, waiting |
post_start hook | extra command after the main one starts | yes with sh -c | warm-up, file generation |
Use bash -c instead of sh -c only if the image has bash - alpine-based images (7.8 MB for alpine:3.20) do not, and you get executable file not found in $PATH. The Compose file reference for command documents both the string and the list form.
2. What happens without sh -c
This is what most people write first:
1services:
2 wrong:
3 image: alpine:3.20
4 command: echo one && echo two
1$ docker compose up wrong
2Attaching to wrong-1
3wrong-1 | one
4wrong-1 exited with code 0
echo two never ran, and nothing told me. I wanted to see what Compose had actually done with the line, so I tried &&, ; and | side by side and asked Compose to print its parsed configuration:
1$ docker compose config
2name: parse
3services:
4 andand:
5 command:
6 - echo
7 - one
8 image: alpine:3.20
9 pipe:
10 command:
11 - echo
12 - one
13 image: alpine:3.20
14 semi:
15 command:
16 - echo
17 - one
18 image: alpine:3.20
All three became [echo, one]. Compose splits a string command into words like a shell would, but it is not a shell, and it stops at the first shell operator. docker compose config is the quickest way to check what Compose will really run - I now use it whenever a service "does nothing".
3. sh -c with && or ;
Hand the whole string to a shell and every operator works:
1services:
2 shc:
3 image: alpine:3.20
4 command: sh -c "echo one && echo two"
5 list:
6 image: alpine:3.20
7 command: ["sh", "-c", "echo one; echo two; echo three"]
1$ docker compose up shc list
2list-1 | one
3list-1 | two
4list-1 | three
5shc-1 | one
6shc-1 | two
7list-1 exited with code 0
8shc-1 exited with code 0
Both forms do the same thing. I prefer the list form when the command itself contains quotes, because YAML and the shell then never disagree about where a string ends.
4. Multiple lines - the list form with a block
Past three commands, a one-liner becomes unreadable. A YAML block scalar (|) keeps every command on its own line:
1services:
2 block:
3 image: alpine:3.20
4 command:
5 - sh
6 - -c
7 - |
8 set -e
9 echo "step 1: migrate"
10 echo "step 2: seed"
11 echo "step 3: start"
1$ docker compose up block
2block-1 | step 1: migrate
3block-1 | step 2: seed
4block-1 | step 3: start
5block-1 exited with code 0
set -e makes the shell stop at the first command that fails, which is what you want for a sequence of setup steps (section 6 shows the difference). The same block works for entrypoint: if you need to replace the image's entrypoint instead of its command.
5. Variables - $VAR versus $$VAR
Compose interpolates $VAR in the YAML file before it starts anything, using the environment of the shell where you run docker compose. To leave a $ for the container's shell, escape it as $$:
1services:
2 vars:
3 image: alpine:3.20
4 environment:
5 GREETING: hello
6 command: sh -c "echo compose-time [$GREETING] container-time [$$GREETING]"
1$ docker compose up vars
2time="2026-10-14T06:24:45Z" level=warning msg="The \"GREETING\" variable is not set. Defaulting to a blank string."
3vars-1 | compose-time [] container-time [hello]
GREETING exists inside the container (from environment:), but not in my terminal, so $GREETING became an empty string and Compose warned about it. $$GREETING reached the container's sh as $GREETING and expanded to hello. The rules are in the Compose interpolation reference.
6. When one command fails - semicolon versus &&
1services:
2 semicolon:
3 image: alpine:3.20
4 command: sh -c "false; echo semicolon keeps going"
5 andand:
6 image: alpine:3.20
7 command: sh -c "false && echo never printed"
1$ docker compose up semicolon andand
2semicolon-1 | semicolon keeps going
3semicolon-1 exited with code 0
4andand-1 exited with code 1
5
6$ docker compose ps -a --format 'table {{.Service}}\t{{.Status}}'
7SERVICE STATUS
8andand Exited (1) Less than a second ago
9semicolon Exited (0) Less than a second ago
With ; the failure of false is hidden and the service even exits 0. With && the chain stops and the exit code 1 is preserved, so restart: policies and depends_on conditions can react to it. For setup steps, use && or set -e.
7. A setup step before the app - a one-shot service and depends on
A very common reason to chain commands is "run the migrations, then start the app". Putting both in one command works, but the migration then runs inside the app container on every restart. Compose has a cleaner way: a one-shot service, and a dependency that waits for it to finish successfully:
1services:
2 migrate:
3 image: alpine:3.20
4 command: sh -c "echo running migrations; sleep 2; echo migrations done"
5 app:
6 image: nginx:1.27-alpine
7 depends_on:
8 migrate:
9 condition: service_completed_successfully
1$ docker compose up -d app
2 Container multi-migrate-1 Created
3 Container multi-app-1 Created
4 Container multi-migrate-1 Starting
5 Container multi-migrate-1 Started
6 Container multi-migrate-1 Exited
7 Container multi-app-1 Starting
8 Container multi-app-1 Started
9
10$ docker compose logs migrate
11migrate-1 | running migrations
12migrate-1 | migrations done
app was only started after migrate exited 0. If the migration fails, app is not started at all. I timed both conditions three times each:
1run 1: docker compose up -d app returned after 2953 ms
2run 2: docker compose up -d app returned after 2495 ms
3run 3: docker compose up -d app returned after 2912 ms
4service_started run 1: returned after 447 ms
5service_started run 2: returned after 438 ms
6service_started run 3: returned after 431 ms
Across the three runs the wait took between 2495 ms and 2953 ms, against 431 to 447 ms with service_started. So service_completed_successfully costs exactly the migration time (2 seconds of sleep here) plus about half a second of container start, and in exchange the app never sees an un-migrated database. The three conditions (service_started, service_healthy, service_completed_successfully) are described in the depends_on reference.
8. Running a command after the container starts - the post start hook
Sometimes the extra command has to run next to the main process, not before it. Compose v2.30 added lifecycle hooks for that:
1services:
2 web:
3 image: nginx:1.27-alpine
4 post_start:
5 - command: sh -c "date +%T > /usr/share/nginx/html/warm.txt"
1$ docker compose up -d web
2 Container multi-web-1 Started
3
4$ docker compose exec web cat /usr/share/nginx/html/warm.txt
506:24:51
nginx kept its own CMD, and my command ran once the container was up. The hook runs as a separate process inside the container, next to nginx, not instead of it. Docker documents post_start and pre_stop on the Compose lifecycle hooks page.
9. Real examples - Django, Flask with Celery, Node
These are the patterns from the original post, corrected to use the forms above. I did not run these three frameworks for this rewrite (the forms themselves are what I tested); treat them as templates.
Django - migrate, collect static, then serve:
1services:
2 web:
3 build: .
4 command:
5 - sh
6 - -c
7 - |
8 set -e
9 python manage.py migrate --noinput
10 python manage.py collectstatic --noinput
11 exec gunicorn mysite.wsgi:application --bind 0.0.0.0:8000 --workers 3
exec on the last line makes gunicorn replace the shell as PID 1, so docker compose stop sends SIGTERM straight to it instead of to sh.
Flask and a Celery worker - two services, not one command:
1services:
2 web:
3 build: .
4 command: ["flask", "run", "--host=0.0.0.0"]
5 worker:
6 build: .
7 command: ["celery", "-A", "app.celery", "worker", "--loglevel=info"]
Running both with & inside one container means Docker only watches one of them. Two services can be restarted, scaled and logged separately.
Node - install, then start with a dev server:
1services:
2 app:
3 image: node:22-alpine
4 working_dir: /app
5 volumes: ["./:/app"]
6 command: sh -c "npm ci && exec npm run dev"
10. What broke when I ran this
I ran every form as a script on a fresh GitHub Actions runner (Docker 28.0.4, Compose v2.38.2) on 2026-10-14.
1. The broken form did not break. I expected command: echo one && echo two to print the literal text one && echo two (that is what I had written in my head for this post). It printed one and exited 0. Only docker compose config showed me that Compose had thrown away the rest of the line. A command that silently does half its job is much worse than one that fails, so this is the main reason this post now says "always sh -c".
2. Every run warned about orphan containers. Because I reused one project directory and changed the service names between steps, each docker compose up printed Found orphan containers ([multi-wrong-1]) for this project. It is harmless, but it hides real warnings - docker compose down --remove-orphans between experiments keeps the output clean.
3. The $VAR mistake only showed up as a warning. The "GREETING" variable is not set. Defaulting to a blank string. is easy to scroll past in a long up output, and the service still exited 0 with an empty value. If a command that uses variables behaves oddly, run docker compose config and look for blanks.
11. Conclusion
To run multiple commands in a Docker Compose service:
- Wrap them in a shell:
sh -c "cmd1 && cmd2", or the list form["sh", "-c", "..."]. - For many lines, use the list form ending in a
|block, starting withset -e. - Never use
&&,;or|without a shell - Compose silently drops everything after them. - Escape container-side variables as
$$VAR. - Put one-shot setup steps in their own service with
depends_on: condition: service_completed_successfully, and post-start work in apost_starthook.
If you need to get into the running container to check what the command did, attach and detach without stopping it or use docker compose exec. And if a service keeps restarting after you change its command, restarting a single container explains why docker compose restart does not pick up a new command but docker compose up -d does.
Did this save you a debugging session? Stay in the loop -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.