Drop a bash or python service into a compose.yaml and it exits immediately on up. The container starts, sees no stdin, prints nothing, and stops. The fix is the Compose equivalent of docker run -it: tty: true and stdin_open: true.
What each flag does
The two flags map directly to the docker run flags everyone has typed a thousand times:
stdin_open: true⇔docker run -i— keep STDIN open even when not attached.tty: true⇔docker run -t— allocate a pseudo-TTY.
services:
shell:
image: alpine
command: sh
stdin_open: true
tty: true
Set both for an interactive shell. Set only tty: true for a process that wants a terminal (for color output, line-buffered logs) but doesn’t read from stdin. stdin_open: true alone is rare — almost always paired with tty.
The Compose-specific gotcha
Setting these on a service started by docker compose up is half the story. By default, up attaches all services in a single multiplexed terminal — keystrokes don’t go to one specific container. Two things make the interactive flow actually work:
- Start only the interactive service in the foreground, or
- Use
docker compose attach <service>afterup -d, or - Use
docker compose run(Tip #79) which always attaches.
A reproducible pattern:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
psql:
image: postgres:16
depends_on: [db]
command: psql -h db -U postgres
stdin_open: true
tty: true
docker compose up -d db
docker compose run --rm psql
The psql service is shaped to be interactive; run is the verb that actually lets you type into it.
Real use cases
Where tty: true + stdin_open: true earn their keep:
- Debug toolboxes: a service like
image: nicolaka/netshootyou bring up on demand to poke at the network. - Language REPLs: pinned versions of
python,node,ghci,irbin a stack so contributors hit the same interpreter. - CLI clients:
redis-cli,mongosh,mysqlconfigured against the in-stack database. - Migration runners: an interactive migration tool that asks for confirmation before each step.
A REPL example:
services:
app:
build: .
repl:
build: .
command: python -i
depends_on: [app]
volumes:
- ./:/app
stdin_open: true
tty: true
docker compose run --rm repl
You’re dropped into a Python REPL inside an image that already has your app’s dependencies installed.
When you need only one
- Only
tty: true: a daemon that detects a TTY and switches its logging format (color, progress bars). Common with CLIs that wrap a long-running process. - Only
stdin_open: true: a service that reads piped input from stdin without needing a terminal —command: ["sh", "-c", "cat | wc -l"]fed from a CI step.
The everyday case is both. Tools that detect “am I running interactively” check the TTY, so set them together unless you have a specific reason not to.
Pair with attach for hands-off scenarios
If the interactive service isn’t where the action is, set attach: false (Tip #75) to keep its logs out of up. Bring it forward only when you want it:
services:
repl:
image: python
command: python -i
stdin_open: true
tty: true
attach: false # hidden during normal `up`
docker compose attach repl # surface it on demand
Pro tip: docker run -it muscle memory
If your fingers still type docker run -it, the Compose equivalent of each letter is worth memorising once:
-i→stdin_open: true-t→tty: true
The reverse cheat works too: when reading a compose.yaml with both flags set, picture it as -it and the intent of the service becomes obvious.