SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Docker Compose exec: get an interactive shell

Open a shell in a running Compose service with docker compose exec, and use run --rm for a service that is stopped or must not be disturbed.

Get an interactive shell with docker compose exec

docker compose exec web bash opens an interactive shell inside the container that is already running as the web service. The name after exec is the service name from your compose.yaml, not the container name. If the image has no bash, ask for sh instead.

docker compose ps
docker compose exec web bash

Run docker compose ps first. It should list web with the state running. Then the second command puts you at a prompt inside the container, and exit or Ctrl-D returns you to the host. The service keeps running after you leave, because exec started a second process beside the main one. Closing your shell does not touch PID 1 (process id 1), the process the container was built to run.

That is one of the two ways in. exec joins a container that already exists. docker compose run creates a new container from the same service definition. Almost everything else in this guide follows from that single difference.

Why -it is optional in Compose but required with plain docker

Two flags control the interactive part of a session. -i keeps stdin open, so what you type reaches the process. -t allocates a pseudo terminal, called a TTY, so the shell prints a prompt and handles arrow keys. Plain docker exec leaves both off by default, which is why every example you have seen writes docker exec -it. docker compose exec turns both on for you, so docker compose exec -it web bash and docker compose exec web bash do the same thing. Compose still accepts -it so old muscle memory keeps working.

You notice a missing TTY within seconds. The shell runs, but it prints no prompt and Ctrl-C never reaches the process. The opposite case, where you must ask Compose not to allocate a TTY, has its own flag and its own section further down.

What to do when the image has no bash

Ask an Alpine based image for bash and exec fails like this:

OCI runtime exec failed: exec failed: unable to start container process: exec: "bash": executable file not found in $PATH: unknown

That message is not an exec problem. It says the binary you asked for is not in the image. Alpine ships BusyBox, which provides ash as /bin/sh and no bash at all, so ask for sh:

docker compose exec web sh

Debian and Ubuntu based images, including the -slim tags, do carry bash, and bash gives you command history and better completion. So try bash first and fall back to sh. sh exists in nearly every general purpose image.

Some images have no shell at all. Distroless images and images built FROM scratch hold the application binary and its libraries and nothing else, on purpose, because a shell that is not there cannot be used against you. In those, sh fails with the same message and there is nothing left to try. Two approaches work. Google's distroless images publish :debug tags that add a BusyBox shell, so switching the tag temporarily gets you in. Or start a separate container inside the target's namespaces:

CID=$(docker compose ps -q web)
docker run --rm -it --network "container:$CID" --pid "container:$CID" nicolaka/netshoot

You now have netshoot's tools pointed at the application's network, so curl localhost:8080 and ss -lntp behave as if you were inside it. The filesystem you see belongs to netshoot, not to the app. Because the process namespace is shared, ls /proc/1/root/ reaches the target's own files when you are root.

When the service is not running, use docker compose run --rm

exec needs a running container. Point it at a stopped service and it refuses:

service "web" is not running

It will not start anything for you. docker compose run will:

docker compose run --rm web bash

run creates a new container from the web service definition, with the same image, environment, volumes and networks, and it replaces the service's command with the one you typed. --rm deletes that container when you exit. Leave --rm off and the leftovers collect under names like myproject-web-run-4f1c2b, which docker compose ps -a will show you and which nothing else cleans up.

Two behaviours of run surprise people. It does not publish the service's ports unless you add --service-ports, and that is deliberate: a second container binding host port 8080 while the first still holds it would fail with bind: address already in use. It also starts everything the service lists under depends_on before your shell appears, so a quick look inside can start a database and a cache. --no-deps skips that.

run goes through the image's ENTRYPOINT, and exec does not. exec starts your command directly in the existing container, so the entrypoint script never sees it. Under run, your bash arrives as arguments to that script. Many official images end their entrypoint with exec "$@", so it passes straight through and you get your shell. A script that interprets its own arguments instead will do something else with them, and then you replace the entrypoint for that one run:

docker compose run --rm --entrypoint sh web

This is the most common reason a command that works under exec behaves differently under run, and the split between command and entrypoint explains which half of the image configuration you are replacing each time.

exec or run: how to choose

  • exec needs a running container. run does not, and run may start dependencies.
  • exec sees the live process list and the files as they are right now, including whatever the application has written since it started. run gets a clean copy of the image, so none of that is there.
  • exec skips the entrypoint. run runs it.
  • run leaves a container behind unless you pass --rm.

Use exec to look at what is actually happening. Use run --rm for a throwaway copy of the same environment, for a one off migration command, or when the real service will not stay up long enough to exec into.

Useful exec flags: user, working directory and replicas

Most images drop to a non-root user, so installing a diagnostic tool inside your exec shell stops here:

E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)

-u root gives you a root shell in the same container:

docker compose exec -u root web sh

-w /srv/app sets the working directory for that command only. -e KEY=value adds an environment variable to your session and not to the service. When a service runs more than one replica, --index 2 decides which container you land in. If file ownership on a mounted directory is what you are chasing, PUID and PGID in container images covers why the numeric ids, not the user names, decide who may write there.

Get a psql or mysql shell inside the database container

The client is already inside the database image, so you do not need one on the host and you do not need to publish the port:

docker compose exec db psql -U postgres -d app
docker compose exec db mariadb -u root -p

Postgres images carry psql, MySQL images carry mysql, and MariaDB images carry mariadb. The connection is made from inside the container, so this works even when the compose file publishes no database port at all. That is the safer arrangement: nothing on the internet can reach a port you never published.

One trap costs people an afternoon. Your shell expands variables on the host, before Docker sees the command, so -U "$POSTGRES_USER" sends an empty string when that variable exists only inside the container. Single quotes and a shell inside the container expand it in the right place:

docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"'

Do not reach for docker compose run --rm db with no command here. That starts a second Postgres server against the same data volume, and it refuses to start:

FATAL:  lock file "postmaster.pid" already exists

The lock file is doing its job, because two servers writing one data directory would corrupt it. While the database is up, exec into the running container. Whether the database belongs in Compose at all is a separate decision, and running the database in Docker or on the host lays out the trade.

Services that need a console at startup: stdin_open and tty

exec and run cover shells you open by hand. A service whose main process is interactive by nature needs two keys in the compose file:

services:
  console:
    image: python:3.12-slim
    command: python
    stdin_open: true
    tty: true

stdin_open: true is docker run -i and tty: true is docker run -t. Without them the container starts and exits at once with code 0, and docker compose ps -a shows Exited (0). Nothing crashed. python with no terminal on stdin reads end of file immediately and quits normally, which is the correct behaviour for a program nobody is typing at.

With both keys set, attach to the running process:

docker attach $(docker compose ps -q console)

Detach with Ctrl-P then Ctrl-Q, which leaves the process running. That sequence works only when the container has both a TTY and stdin open. Ctrl-C instead sends an interrupt to PID 1 and stops the service.

Leave both keys off for ordinary services. A web server never reads stdin, and tty: true makes many programs switch to colour output and line buffering because they believe a person is watching, which fills docker compose logs with escape codes.

Why scripted exec breaks in cron and CI: the -T flag

An exec command that works in your terminal fails inside a cron job or a continuous integration (CI) runner:

the input device is not a TTY

Compose asks for a pseudo terminal by default, and cron gives the job no terminal, so the request fails before your command ever runs. -T turns the request off:

0 3 * * * docker compose -f /srv/app/compose.yaml exec -T db pg_dump -U postgres -Fc app > /srv/backups/app.dump

-T matters for a second reason. A TTY rewrites the byte stream on its way out, so a compressed dump that passes through one arrives damaged. Any redirected or piped output needs -T.

Two more cron details. Pass -f with an absolute path, because cron runs the job from the home directory where there is no compose file, and Compose then stops with no configuration file provided: not found. And exec returns the exit code of the command it ran, so a failing pg_dump fails your script under set -e instead of writing an empty backup and reporting success. The rest of the everyday commands are collected in a Compose command cheat sheet worth keeping beside those scripts.

Why the changes you make inside a container disappear

You install a tool with exec, edit a config file, fix the problem, and a week later the fix is gone. That is the container's writable layer behaving as designed. docker compose up -d after any change to the image tag or the service definition destroys the old container and builds a new one from the image, and every hand edit goes with the old container.

docker compose restart is different. It stops and starts the same container, so hand edits survive it. That is why a manual fix can appear to hold for weeks and then vanish during an unrelated update. Named volumes and bind mounts survive both operations, because their data lives outside the container, and bind mounts and named volumes covers which one to choose for data you intend to keep.

So treat an exec shell as a place to read and test. Once you know the fix, write it where it survives: a package into the Dockerfile, a setting into the compose file. Then docker compose up -d to apply it, and confirm with another exec that the new container really has it.

FAQ

What is the difference between docker compose exec and docker compose run?

exec runs a command inside a container that is already running, beside the main process, and it skips the image entrypoint. run creates a new container from the same service definition with the same image, environment, volumes and networks, passes your command through the entrypoint, and starts any depends_on services first. run also leaves the service's ports unpublished unless you add --service-ports. Use exec to inspect the live service. Use run --rm when the service is stopped or when you do not want to disturb it.

Why does docker compose exec report that the service is not running?

exec attaches to an existing container and cannot create one, so a stopped or crashed service gives service "web" is not running. Check docker compose ps -a, which lists exited containers with a status such as Exited (1), and read docker compose logs web for the reason it stopped. To get a shell anyway, run docker compose run --rm --entrypoint sh web. That builds a fresh container from the same service definition without letting the broken start command run.

How do I open a shell when the image has no bash?

docker compose exec web bash failing with exec: "bash": executable file not found in $PATH means bash is absent from the image, which is normal for anything built on Alpine. Use docker compose exec web sh, because BusyBox provides /bin/sh. Distroless and scratch images contain no shell at all, so no exec command will work. Switch to the image's :debug tag if the publisher offers one, or start a debug container in the target's namespaces with docker run --rm -it --network "container:$CID" --pid "container:$CID" nicolaka/netshoot, where $CID comes from docker compose ps -q web.

Why does my exec command fail with "the input device is not a TTY" in cron?

docker compose exec requests a pseudo terminal by default and cron provides none, so the request fails before your command runs. Add -T to switch it off: docker compose exec -T db pg_dump -U postgres app. Use -T for any redirected or piped output too, since a TTY alters the byte stream and damages a binary dump. In cron, also pass -f with the absolute path to your compose file, or Compose exits with no configuration file provided: not found.

Do changes I make inside a container with exec survive a restart?

They survive docker compose restart, which reuses the same container. They are lost on docker compose up -d after any image or configuration change, because that recreates the container from the image and discards its writable layer. Data written into named volumes or bind mounts survives both, since it lives outside the container. Make diagnostic changes with exec, then put the permanent version in the Dockerfile or the compose file.