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 two printed only one, and docker compose config shows 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 with set -e so the first failure stops the rest.
  • Write $$VAR inside the command for variables the container should expand. A single $VAR is 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 made docker compose up -d wait 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.

What Docker Compose runs for each form of the command key

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

  1. The forms at a glance
  2. What happens without sh -c
  3. sh -c with && or ;
  4. Multiple lines - the list form with a block
  5. Variables - $VAR versus $$VAR
  6. When one command fails - semicolon versus &&
  7. A setup step before the app - a one-shot service and depends on
  8. Running a command after the container starts - the post start hook
  9. Real examples - Django, Flask with Celery, Node
  10. What broke when I ran this
  11. Conclusion

1. The forms at a glance

How you write command:What runs&& ; | $VAR work?Use it for
echo one && echo twoecho one only - the rest is droppednonever with operators
sh -c "echo one && echo two"a shell runs the whole stringyesone-liners
["sh", "-c", "echo one; echo two"]same, quotes are explicityesone-liners with tricky quoting
list form ending in - | blocka shell runs every lineyesthree or more steps
a separate service + depends_oneach step in its own containern/amigrations, seeding, waiting
post_start hookextra command after the main one startsyes with sh -cwarm-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:

  1. Wrap them in a shell: sh -c "cmd1 && cmd2", or the list form ["sh", "-c", "..."].
  2. For many lines, use the list form ending in a | block, starting with set -e.
  3. Never use &&, ; or | without a shell - Compose silently drops everything after them.
  4. Escape container-side variables as $$VAR.
  5. Put one-shot setup steps in their own service with depends_on: condition: service_completed_successfully, and post-start work in a post_start hook.

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 -