Docker exec: debugging containers, shells & pitfalls (2026)

By Kate Baker
Docker exec: debugging containers, shells & pitfalls (2026)

Most docker exec tutorials stop at docker exec -it mycontainer bash. That works until the moment you actually need it: the image has no bash, the container restarts every four seconds, the environment variable you are checking prints empty, or your database dump comes out with garbage line endings. This guide skips the flag tour and goes straight to those situations, because that is where docker exec either saves your afternoon or quietly wastes it.

Every example below was written against current Docker Engine and Docker Compose v2 (docker compose, not docker-compose).

What docker exec actually does

A clear mental model prevents most of the confusion later.

docker exec starts a new process inside the namespaces of an already running container. It shares the container's filesystem, network stack, PID namespace and cgroup limits. It does not:

  • restart or re-run the container's entrypoint,
  • attach to the main process (that is docker attach),
  • work on a stopped or restarting container,
  • inherit variables that your entrypoint script exported at runtime.

That last point trips people up constantly. The exec'd process gets the environment from the container's config (ENV in the Dockerfile, -e or environment: at run time), not from whatever PID 1's shell computed after start.

The flags you will use in practice:

FlagWhat it does
-iKeep STDIN open
-tAllocate a pseudo-TTY
-u / --userRun as a specific user or UID (-u 0 for root)
-w / --workdirSet the working directory
-e / --env-fileAdd environment variables for this process only
-dRun the command in the background
--privilegedGive the process extended privileges

Scenario 1: "executable file not found" when opening a shell

You run the classic command and get this:

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

Alpine, BusyBox-based and many slim images ship sh but not bash. Try the fallbacks in order:

docker exec -it api sh
docker exec -it api /bin/sh
docker exec -it api ash

A one-liner that works on nearly anything with a POSIX shell:

docker exec -it api sh -c 'command -v bash >/dev/null && exec bash || exec sh'

If none of those work, you are probably looking at a distroless or scratch image. That is scenario 2.

Exit code 126 from docker exec means the file exists but is not executable. Exit code 127 means the command was not found. They point to different problems, so check echo $? before guessing.

Scenario 2: debugging a distroless or scratch image

Distroless images (Google's gcr.io/distroless/*, Chainguard images, Go binaries on scratch) have no shell, no ls, no cat. That is great for security and terrible at 11 PM when something is wrong. docker exec can only run binaries that exist inside the container, so you need to bring your own tools.

Option A: a debug sidecar that shares namespaces

Start a throwaway container that joins the target's PID and network namespaces:

docker run --rm -it \
  --pid=container:api \
  --network=container:api \
  nicolaka/netshoot

Inside that container you can now:

ps aux                 # see the api process
ss -tlnp               # see what it is listening on
curl -v localhost:8080/health
ls /proc/1/root/app    # browse the target's filesystem via /proc

The /proc/1/root trick works because you share the PID namespace. PID 1 is the target's main process, and its root directory is the target's filesystem. You may need --cap-add SYS_PTRACE for some inspection tools.

Option B: docker debug

Recent versions of Docker Desktop include docker debug, which attaches a toolbox shell to any container, even one without a shell, without modifying the image:

docker debug api

It is convenient on a laptop. It is not part of the plain open-source Docker Engine, so on a Linux server you will usually fall back to option A.

Option C: build a debug variant

Many distroless families publish a :debug tag with BusyBox included. For your own images, a multi-stage Dockerfile makes this cheap:

FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/api ./cmd/api

FROM gcr.io/distroless/static-debian12 AS prod
COPY --from=build /out/api /api
ENTRYPOINT ["/api"]

FROM busybox:stable AS debug
COPY --from=build /out/api /api
ENTRYPOINT ["/api"]

Build with --target debug when you need to poke around locally. Keep shipping the prod target.

Scenario 3: the container keeps restarting

$ docker exec -it worker sh
Error response from daemon: Container 3f2a... is restarting, wait until the container is running

You cannot exec into something that is not running. Stop fighting the restart loop and collect evidence first:

docker logs --tail 100 worker
docker inspect worker --format \
  'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}'
  • exit=137 oom=true: the kernel killed it for exceeding its memory limit.
  • exit=1 with a stack trace in the logs: an application error, usually config.
  • exit=0 in a loop: your main process finishes immediately (a common mistake with scripts that background the real server).

To get a shell in the same image and configuration without the crashing command, override the entrypoint:

docker compose run --rm --entrypoint sh worker

docker compose run reuses the service's image, env vars, volumes and networks, so you are debugging in a near-identical environment. With plain Docker:

docker run --rm -it --entrypoint sh \
  --env-file .env \
  -v "$PWD/data:/data" \
  myorg/worker:1.4.2

Another option is to temporarily set command: ["sleep", "infinity"] in compose.yaml, bring the service up, then docker compose exec worker sh and start the real process by hand to watch it fail.

Scenario 4: the environment variable is empty (or wrong)

You want to confirm the database URL:

docker exec api echo $DATABASE_URL

It prints your host's value, or nothing. Your local shell expanded $DATABASE_URL before Docker ever saw the command. Quote it and let a shell inside the container do the expansion:

docker exec api sh -c 'echo "$DATABASE_URL"'

Or just dump everything:

docker exec api env | sort

If the variable is still missing, check two things:

  1. Was it set in the container config?docker inspect api --format '{{json .Config.Env}}' shows what Docker passed in. If it is not there, the container needs to be recreated with it. Editing .env does not affect running containers. Run docker compose up -d so Compose recreates the changed service.
  2. Is it computed by the entrypoint? If docker-entrypoint.sh does export DATABASE_URL=... from secrets files, only PID 1 and its children see it. Your exec session will not. Read /proc/1/environ instead:
docker exec api sh -c 'tr "\0" "\n" < /proc/1/environ | sort'
Careful: docker exec api env and docker inspect both print secrets in plain text. Do not paste their output into tickets or chat.

Scenario 5: "permission denied" inside the container

Well-built images run as a non-root user, so your exec session does too. Installing a package or reading a root-owned file fails.

docker exec -it -u 0 api sh

-u 0 (or -u root) gives you root for that session only. Two warnings from experience:

  • Files you create as root stay root-owned. If you touch or edit something in a mounted volume, the app user may no longer be able to write it. Match the app's UID when you edit app files: docker exec -u "$(docker exec api id -u)" api ..., or check with ls -ln afterwards.
  • Package installs are temporary.apk add curl inside a running container disappears on the next recreate. That is fine for debugging. It is not a fix. If you needed the tool twice, put it in a debug target (see scenario 2).

Scenario 6: talking to your database through docker exec

This is the most common legitimate everyday use, and it hides the nastiest pitfall.

Interactive sessions:

docker compose exec db psql -U postgres -d app
docker compose exec redis redis-cli
docker compose exec mongo mongosh

Backups and restores, the part that goes wrong:

# Correct: -T disables the pseudo-TTY
docker compose exec -T db pg_dump -U postgres -d app > backup.sql

# Restore
docker compose exec -T db psql -U postgres -d app < backup.sql

docker compose exec allocates a TTY by default. A TTY translates line endings and can mix stderr into stdout, which gives you a dump file with \r\n line endings or stray warnings in the middle of the SQL. It may look fine and then fail on restore. With plain docker exec, just leave out -t:

docker exec -i pg pg_dump -U postgres -d app > backup.sql

Rule of thumb: use -t only when a human is typing. Never use it when piping.

Also, a dump you create via exec is only as good as the place you store it. A backup.sql sitting on the same VPS as the database is not a backup strategy. Copy it off the machine, or use a managed database with automated backups.

Scenario 7: docker exec in scripts and CI

In a GitHub Actions job or cron script, this fails:

docker exec -it api python manage.py migrate
# the input device is not a TTY

CI runners have no terminal. Drop -t (and usually -i) in non-interactive contexts:

docker exec api python manage.py migrate
docker compose exec -T api python manage.py migrate

Other scripting details worth knowing:

  • docker exec returns the exit code of the command it ran, so set -e in your script works as expected.
  • -w saves you from sh -c 'cd /app && ...': docker exec -w /app/src api ls.
  • -e adds variables for that one process: docker exec -e LOG_LEVEL=debug api ./healthcheck.
  • docker exec -d runs a command in the background. The process dies when the container stops, and its output is not in docker logs, because that only captures PID 1.

Windows and Git Bash

On Git Bash, MSYS rewrites paths that look like Unix paths, so /bin/sh turns into something like C:/Program Files/Git/bin/sh. Prefix with a double slash or disable conversion:

docker exec -it api //bin/sh
MSYS_NO_PATHCONV=1 docker exec -it api /bin/sh

Pitfalls that bite experienced users

Treating exec as a deploy tool. Editing config inside a running container with vi fixes the symptom and creates drift. The next deploy, restart or scale-out brings the bug back and nobody knows why it "used to work." Use exec to learn what is wrong, then fix it in the Dockerfile, the Compose file or the environment config.

Forgetting it is root-equivalent access. Anyone who can run docker exec can run docker exec -u 0 --privileged. Membership in the docker group is effectively root on the host. Treat it that way in team setups.

Exec into the wrong replica. With docker compose up --scale api=3, docker compose exec api targets replica 1. Use --index 2 to reach another one. The bug you are chasing might only live on one of them.

Detaching incorrectly.exit or Ctrl+D ends your exec session and leaves the container running. That is safe. With docker attach, by contrast, Ctrl+C may stop the main process. Use Ctrl+P Ctrl+Q to detach from attach sessions.

Assuming exec sees what the app sees. Exec'd processes skip the entrypoint, so they may miss runtime-computed env vars, a different working directory or a changed user. When behavior differs, compare with /proc/1/environ and docker inspect.

A quick debugging checklist

When a container misbehaves, work through this in order:

  1. docker ps -a to see if it is running, restarting or exited.
  2. docker logs --tail 100 to see what the main process said.
  3. docker inspect to check exit code, OOM flag, env and mounts.
  4. docker exec -itsh to look around, if it is running and has a shell.
  5. Debug sidecar with --pid and --network joined, if it has no shell.
  6. docker compose run --rm --entrypoint sh if it will not stay up.
  7. Fix the root cause in code or config, then rebuild. Do not patch it live.

Debugging when there is no docker exec

On a managed platform you usually do not have a host to SSH into, and a shell on a production container is not always available. This is not a loss. It pushes you toward habits that also make docker exec sessions shorter:

  • Log to stdout with enough context (request IDs, config summaries at startup, clear errors on bad env vars) so logs alone answer most questions.
  • Fail fast on bad configuration. Validate required env vars at boot and exit with a readable message instead of crashing on the first request.
  • Add a health endpoint that checks real dependencies such as the database and storage, not just "process is alive."
  • Reproduce locally with the same image tag and a copy of the env vars, where docker exec is always available.

That is roughly how we think about it at Granite.so: you deploy from Git or a Docker image and the platform rebuilds on push, so a fix goes into the repository, not into a live container. If you are tired of SSHing into a VPS to exec into containers at 2 AM, Granite.so runs your containers alongside managed databases and S3-compatible storage, starting with the Hobby plan at $9/month with $15 of monthly usage credits included. Keep docker exec for your laptop, where it belongs.

Keep reading

More posts tagged “Tutorials”.
Be first in line for updates
and special pricing
Get early access to new features and exclusive discounts delivered straight to your inbox