You want a shell inside one of your Compose services to poke at the filesystem, check an environment variable, or run a quick migration, and you're not sure whether to use exec or run, or why the -it flags that feel mandatory with plain Docker don't seem necessary here.
The short version: docker compose exec drops you into a service that's already running, and docker compose run spins up a brand-new throwaway container from the same service definition. Both allocate a pseudo-terminal (TTY) and keep standard input (STDIN) open by default, which is the opposite of how docker exec and docker run behave. This article covers both commands, the flag confusion, and how to pick the right one.
Get a shell in a running service with exec
This is the case you'll hit most often. Your stack is up, something looks off, and you want to look inside the running container. Start your services if they aren't already:
1docker compose up -d
Then exec a shell into the service by name. Use the service name from your compose.yaml, not the container name:
1docker compose exec web bash
You'll land at a prompt inside the container:
1root@a4f8c9e12345:/app#
If the image is based on Alpine or another minimal distro, bash won't be installed and you'll get executable file not found. Fall back to sh, which is present almost everywhere:
1docker compose exec web sh
The thing that trips people up here is the -it flags. With plain docker exec you have to pass --interactive --tty (or -it) to get a usable shell. Compose flips that default: docker compose exec allocates a TTY and runs interactively out of the box, so docker compose exec web sh already gives you a working prompt. Current versions of Compose accept -i and -t anyway as no-ops, purely so the muscle memory of docker compose exec -it web sh doesn't throw an error. Older Compose releases rejected them outright with unknown shorthand flag: 'i', which is where a lot of the confusion online comes from.
Get a shell in a new container with run
Sometimes the service isn't running, or you want a clean container to experiment in without touching your live one. That's what docker compose run is for. It reads the service definition, spins up a fresh container from it, and runs whatever command you give it:
1docker compose run --rm web bash
The --rm flag matters more than it looks. Without it, every run invocation leaves a stopped one-off container behind (named something like myproject-web-run-a1b2c3), and they pile up quietly until a docker compose ps --all reveals a dozen of them. Adding --rm tears the container down as soon as you exit the shell.
Like exec, run is interactive with a TTY by default, so you don't need -it here either.
When to use exec vs run
The decision comes down to one question: do you want the container that's already running, or a new one?
Use exec when you need to inspect or debug the live container, the one actually serving traffic or holding the state you care about. Checking why a process is hung, reading a log file the app just wrote, or confirming an environment variable resolved correctly all require the running instance. A new container wouldn't have any of that runtime state.
Use run for one-off tasks that don't belong to the running service: database migrations, a REPL (read-eval-print loop), a management command, or just trying something in a disposable shell. run is also your only option when the service has crashed or was never started, since there's no running container for exec to attach to.
A concrete example: to open a psql shell against your database service while everything is up, you'd exec into it. To run a Django migration before bringing the stack up, you'd run a one-off container:
12345# Inspect the running databasedocker compose exec db psql -U postgres# Run a migration in a throwaway containerdocker compose run --rm web python manage.py migrate
Common pitfalls
run doesn't publish your service's ports. By design, docker compose run does not bind the ports declared in your compose.yaml, so a one-off container won't conflict with your already-running service on the same port. If you actually need those ports published, for example to hit a dev server you started with run, add --service-ports:
1docker compose run --service-ports web npm run dev
There's a sharp edge here worth knowing: while the run container itself won't publish ports, any dependencies it starts via depends_on can still publish theirs, depending on your Compose version. If you run a service whose dependency is already running with the same published port, the second copy can fail to bind and the command errors out. Use --no-deps to skip starting dependencies when you don't need them.
exec fails if the container isn't running. If the service's main process exited (a container built to run a single command that already finished, for instance), exec has nothing to attach to and you'll get an error about the container not running. Either fix the reason it exited, or use run to start a fresh container with your shell as the command. For containers that are meant to stay up but have no long-running process, a command: sleep infinity in the service definition keeps them alive so you can exec in.
Non-interactive contexts need -T. When you call docker compose exec from a CI (continuous integration) pipeline or a script with no terminal attached, the default TTY allocation can cause the input device is not a TTY errors or hang. Disable it with -T:
1docker compose exec -T web ./run-checks.sh
This is the inverse of the interactive case and the one situation where you do need to think about the TTY flags in Compose.
Final thoughts
Once you can reliably get a shell into your services, the next step is not having to shell in at all to understand what they're doing. Dropping into a container to tail a log or check resource usage works for a one-off, but it doesn't scale past a handful of services, and it tells you nothing about what happened five minutes before you connected.
Dash0's infrastructure monitoring tracks container resource usage alongside real-time logs and distributed traces, so you can see what every service in your Compose stack is doing from a single place instead of exec-ing in one container at a time.
Start a free trial to see your containers, logs, and traces in one view. No credit card required.