50 real Docker interview questions with detailed answers on images, Dockerfiles, networking, volumes and debugging — grouped by difficulty.
An image is a read-only template: a frozen filesystem plus metadata describing what your app needs to run. A container is a running (or stopped) instance created from that image, with a thin writable layer stacked on top for anything the process writes while it runs.
Think of the image as a class and the container as an object built from it. One image can produce many containers, and each container's writes live only in its own writable layer — they never change the image, and they never show up in a sibling container started from the same image.
This distinction is the root of a lot of "why did my data disappear" questions later, so it's worth having cold: images are immutable, containers are disposable.
A Dockerfile is a plain-text set of instructions, read top to bottom, that tells Docker how to build an image. Each line is a separate instruction — FROM picks the base image, RUN executes a command at build time, COPY brings files into the image, and CMD or ENTRYPOINT set what runs when a container starts from it.
Most instructions produce a new image layer, which is why the order of a Dockerfile matters for build speed (see the build-cache question below). The point of writing one at all is reproducibility: anyone with the Dockerfile and the same build context can produce an identical image, instead of someone manually installing packages on a server and hoping it matches production.
Every instruction in a Dockerfile that changes the filesystem (RUN, COPY, ADD, and a few others) creates a new, read-only layer. Docker stacks these layers on top of each other using a union filesystem to present what looks like one single filesystem inside the container, while physically storing only the differences each layer introduces.
You can see them yourself with docker history <image>, which lists every layer and its size, oldest first. Layers are also the unit Docker's build cache and its network transfers work in: if two images share a base, Docker only needs to pull or rebuild the layers that differ, not the whole thing.
docker history myapp:latestBoth instructions bring files from your build context into the image, but ADD does two extra things COPY doesn't: it can fetch a file from a remote URL, and it automatically extracts a local tar archive into the destination.
The common guidance is to default to COPY for everything, because it does exactly one obvious thing, while ADD's auto-extract behavior has surprised plenty of people who just wanted a tarball copied, not unpacked. Reach for ADD only when you specifically want that extraction behavior — for a URL fetch, a RUN curl is usually clearer anyway, since it lets you also verify a checksum.
RUN executes at build time and bakes its result into a layer — this is how you install packages or compile code while the image is being built. CMD and ENTRYPOINT both describe what happens when a container starts, not during the build, so neither one runs while you're building the image.
The difference between the two start-time instructions: ENTRYPOINT sets the fixed command that always runs, while CMD supplies default arguments that are easy to override from the command line. A common pattern is ENTRYPOINT ["python"] with CMD ["app.py"], so docker run myimage other.py swaps the script but keeps the interpreter fixed.
EXPOSE in a Dockerfile is documentation — it records which port the container's process listens on, but by itself it doesn't open anything to the outside world. Publishing a port with -p 8080:80 (or --publish) is the instruction that actually maps a port on the host to a port in the container, so traffic from outside can reach it.
A container can work perfectly well with no EXPOSE line at all, as long as you publish the right port when you run it. People confuse the two often enough that stating plainly "EXPOSE doesn't make anything reachable" is a good way to show you've actually published a port before, not just read about it.
Compose is a tool for defining and running a multi-container application from a single YAML file. Instead of running several long docker run commands by hand, you declare each service, its image or build context, its ports, volumes, and environment, plus any networks, once — and bring the whole stack up with docker compose up.
You reach for it as soon as an app is more than one container: an API plus a database plus a cache, for example, or any setup you want a teammate to reproduce locally without a page of instructions. As a bonus, Compose puts your services on a shared user-defined network by default, so they can resolve each other by service name automatically — no manual network setup required.
A registry is a server that stores and serves Docker images, organized into repositories and tags, that you push to and pull from with docker push and docker pull. Docker Hub is simply the default, publicly hosted registry that Docker points to out of the box, and it's where most official images (like nginx or postgres) live.
You aren't limited to Docker Hub — plenty of teams run a private registry instead, using something like Amazon ECR, GitHub Container Registry, GitLab's built-in registry, or a self-hosted one, mainly to keep proprietary images off a public service and control who can pull them.
docker stop sends the process SIGTERM and gives it a grace period — 10 seconds by default — to shut down cleanly, then follows up with SIGKILL if it's still running. docker kill skips the grace period entirely and sends SIGKILL straight away.
So stop is the polite request and kill is the immediate one. In practice you almost always want stop for anything that should close connections or flush data before exiting, and kill only when a container is stuck and you need it gone right now.
ARG defines a build-time variable — it's available only while the image is being built, you can set it with --build-arg, and it does not persist into the running container unless you explicitly copy its value into an ENV. ENV sets an environment variable that's baked into the image and is visible to every container started from it, at runtime.
Use ARG for things that only matter during the build, like choosing a base image version or a build mode; use ENV for values your application actually reads while it's running, like a port number or a log level. A common pattern is defining an ARG and then writing ENV MY_VAR=$MY_ARG to carry a build-time choice into the running container.
.dockerignore is a file, sitting next to your Dockerfile, that lists paths Docker should exclude when it sends your build context to the daemon. Without one, a docker build . uploads everything in that directory — including .git, node_modules, local .env files, and build artifacts — before the build even starts.
It matters for three reasons: it keeps secrets and local config out of the image, it keeps the build context small so builds start faster, and it stops junk files from accidentally invalidating your build cache when they change. A minimal one for a Node project typically excludes .git, node_modules, and any *.log files.
.gitnode_modules*.log.envA virtual machine virtualizes hardware: a hypervisor runs several guest operating systems side by side, each with its own full kernel. A container virtualizes at the operating-system level instead — it's a regular process on the host, isolated using Linux namespaces (which control what it can see, like its own process list and network stack) and cgroups (which control what it can use, like CPU and memory), and it shares the host's kernel with every other container.
That's why containers start in milliseconds and measure in megabytes, while VMs take seconds to boot and measure in gigabytes. The honest trade-off, and the part worth saying out loud in an interview, is that sharing the kernel makes the isolation boundary weaker than a VM's: a kernel-level vulnerability or a misconfigured privileged container has a shorter path to the host than it would in a hypervisor-isolated VM.
| Container | Virtual Machine | |
|---|---|---|
| Isolation unit | Process (namespaces + cgroups) | Full guest OS (hypervisor) |
| Kernel | Shared with host | Own kernel per VM |
| Startup time | Milliseconds | Seconds to minutes |
| Typical size | Megabytes | Gigabytes |
docker run is really three steps compressed into one command. First, Docker checks whether the requested image exists locally, and pulls it from the registry if it doesn't. Second, it creates a container — a writable layer plus the container's configuration — from that image. Third, it starts the container by launching the image's ENTRYPOINT/CMD as PID 1 inside a fresh set of namespaces.
Knowing this breakdown is useful because it tells you where a failure actually happened: an auth error or a "manifest not found" means the pull failed, while a container that starts and exits immediately means the start step ran but the process itself didn't stay alive. docker create followed by docker start is literally the same two steps run separately, which is a good way to see the split for yourself.
docker ps shows running containers, and docker ps -a adds stopped ones. docker images lists every image you have locally, including old, unused layers left over from previous builds.
For cleanup, docker system prune removes stopped containers, unused networks, and dangling images (layers with no tag pointing at them) in one go. docker system prune -a goes further and also removes any image that isn't currently used by a container, tagged or not — which is more aggressive and worth running with --dry-run-style caution, since it can delete images you meant to reuse.
The practical reason this question comes up: stopped containers and orphaned layers quietly eat disk space on build servers and dev machines, and knowing prune exists — and which flag does what — is the difference between a slow disk-cleanup afternoon and one command.
docker system df # see how much space containers/images/volumes are usingdocker system prune # safe cleanupdocker system prune -a # also removes unused (not just dangling) imagesBy default, it doesn't. Anything a container writes goes into its own writable layer, and that layer is deleted the moment the container is removed. To keep data around, you have three options.
Volumes are storage areas that Docker manages for you, living outside any single container's lifecycle — this is the recommended default for anything you actually want to keep. Bind mounts map a specific path on the host straight into the container, which is great for local development because you can edit a file on your machine and see it change inside the container instantly. tmpfs mounts store data in memory only, never touching disk, which suits short-lived secrets you don't want written anywhere permanent.
docker run --rm -v mydata:/data busybox sh -c 'echo hello > /data/file.txt'# container is gone, but a new one reading the same volume still sees the file:docker run --rm -v mydata:/data busybox cat /data/file.txtDocker creates three networks the moment it's installed. Bridge is the default — a private, internal network on the host where each container gets its own IP address. Host removes the network isolation entirely, so the container shares the host's network stack directly. None gives the container no networking at all.
Unless you tell it otherwise, a container joins the default bridge network. That default is fine for a single standalone container, but it has an important limitation covered in the next question: containers on it can't resolve each other by name, which is the single most common Docker networking surprise for people moving from a single container to a small multi-service setup.
Almost always, they're both sitting on Docker's default bridge network, and the default bridge has no built-in DNS — container names simply don't resolve there. Put the same two containers on a user-defined network instead, and Docker's embedded DNS resolves container (or Compose service) names to their IP addresses automatically.
You can see the contrast directly:
# user-defined network: name resolvesdocker network create demonetdocker run -d --name web --network demonet nginxdocker run --rm --network demonet busybox nslookup web# Name: web Address: 172.19.0.2 # default bridge: same lookup failsdocker run -d --name web2 nginxdocker run --rm busybox nslookup web2# ** server can't find web2: NXDOMAINThe fix is one line: create a user-defined network (docker network create) and attach both containers to it, or let Docker Compose do it automatically — every Compose project gets its own user-defined network by default, which is exactly why service names "just work" there.
Because latest isn't a version number that means "the newest release" — it's just the tag Docker applies by default whenever you don't specify one. Nothing about it guarantees it points at the most recent build, and worse, it's a moving target: two docker pull commands a month apart, using the exact same tag, can return two different images.
That means a deployment that worked yesterday can break today with no change on your end, because the tag silently moved underneath you. It also makes rollbacks harder — if something breaks, you have no record of what "worked before" actually was. The fix teams settle on is pinning explicit version tags, or better, image digests, in anything that matters, and treating latest as acceptable only for quick, throwaway local experiments.
Four levers, roughly in order of how much they save. Use a multi-stage build so compilers and build tools never ship in the final image — this is usually the single biggest win, and the next question walks through the numbers. Start from a slim or Alpine base image instead of a full OS image, since a general-purpose distro carries a lot you'll never use in a container. Combine and order RUN instructions carefully, cleaning up package-manager caches in the same layer they were created in, since a rm -rf in a later layer doesn't shrink the layer that already stored the files. And add a .dockerignore so build artifacts, .git, and local dependencies never enter the build context in the first place.
In practice, the multi-stage build and the base image swap together usually account for most of the savings — a bloated single-stage Go image can drop from well over a gigabyte to under 20MB just from those two changes.
Docker's build cache invalidates top-down: the moment one instruction's inputs change, every instruction after it in the file rebuilds from scratch, even if those later instructions are identical to last time. That makes ordering a real performance decision, not just style.
The standard pattern is to copy your dependency manifest and install dependencies before copying your application source, because dependencies change rarely and source code changes on every commit.
FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt # cached unless requirements.txt changesCOPY . . # changes every commit — put it lastCMD ["python", "app.py"]Get the order backwards — copy all the source first, then install dependencies — and a single one-line code change invalidates the dependency-install layer too, forcing a full reinstall on every build.
Configuration — the kind of thing that differs between dev and prod but isn't sensitive — goes in through environment variables, either individually with -e or in bulk with an --env-file, or through mounted config files. That way the same image runs anywhere, with different settings supplied at run time instead of baked into the build.
Secrets are the part interviewers actually care about, because there's a wrong answer people reach for by habit: never bake a secret into the Dockerfile with a RUN or ARG, since it ends up permanently readable in the image's layers — anyone who pulls the image can extract it with docker history or by inspecting the layer filesystem directly, even if a later layer "deletes" the file. For low-sensitivity values, runtime environment variables are fine. For anything that actually matters — API keys, database passwords, certificates — use a real secrets mechanism: Docker/Swarm secrets, or your orchestrator's secret store mounted into the container as a file at runtime, never as a build-time argument.
The exec form, written as a JSON array — CMD ["python", "app.py"] — runs that binary directly as the container's PID 1. The shell form, CMD python app.py, quietly wraps the command as /bin/sh -c "python app.py", so a shell process sits in front of your actual application.
This isn't just a style choice. When a shell wraps your process, signals sent by docker stop can be delivered to the shell instead of being forwarded to your app, which is the root cause of the "why does stop take ten seconds" scenario covered later. The exec form makes your application PID 1 directly, so it receives signals itself — which is why it's the recommended form for anything long-running.
docker run --rm busybox ps -o pid,args# PID COMMAND# 1 ps -o pid,args <- exec form makes the app PID 1 directlyHEALTHCHECK defines a command Docker runs periodically inside the container to decide whether it's actually working, not just running. A container can be in the Up state — its process alive — while the application inside it is deadlocked, unable to reach its database, or stuck returning 500s; docker ps alone won't show that, but a health check will report the container as unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1This matters beyond just visibility: orchestrators and Compose can act on the health status. In Docker Compose, depends_on: condition: service_healthy waits for a dependency to report healthy — not merely started — before bringing up a service that needs it, which is exactly the gap the next question is about.
Not by default, and this trips up a lot of setups. Plain depends_on: - db only waits for the db container to start — it says nothing about whether Postgres inside it has actually finished initializing and is ready to accept connections. An app container can come up, immediately try to connect, and fail, because "started" and "ready" are different things for anything with its own startup sequence.
The fix is to pair depends_on with a health check on the dependency and the service_healthy condition:
services: app: build: . depends_on: db: condition: service_healthy db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5Now app only starts once db's health check passes, not just once its process exists. Without this, the usual workaround is a retry loop in the app's own startup code — worth mentioning as a fallback, since not every dependency has a clean health-check command.
Profiles let you tag services in a compose.yaml so they only start when you explicitly ask for them, instead of maintaining several separate Compose files for slightly different situations. A service with no profiles key always starts with a plain docker compose up; a service tagged with a profile only starts if you pass --profile <name>.
services: app: build: . db: image: postgres:16 debug-tools: image: my-debug-image profiles: ["debug"] monitoring: image: prom/prometheus profiles: ["monitoring"]docker compose up # app + db onlydocker compose --profile debug up # app + db + debug-toolsIt's the right tool whenever you have optional extras — debugging utilities, a monitoring stack, load-testing containers — that most people most of the time don't want cluttering a routine docker compose up.
Docker offers four restart policies, set with --restart on docker run or restart: in Compose. no is the default — a stopped container just stays stopped. on-failure[:max-retries] restarts only if the container exits with a non-zero code, optionally capped at a retry count, which suits a batch job that should retry a real crash but not loop forever on a bad config. always restarts the container no matter how it stopped, including a manual docker stop, and also brings it back up after the Docker daemon itself restarts. unless-stopped behaves like always except it respects a manual stop — if you stopped it on purpose, it stays stopped even across a daemon restart.
For a long-running service you want up and staying up, unless-stopped is usually the practical default: it recovers from crashes and reboots, but doesn't fight you when you deliberately take the container down for maintenance.
Both let a container's data live outside its writable layer, but they differ in who manages the storage. A bind mount maps a specific, existing path on the host filesystem straight into the container — you control the path, and anything on the host at that path is visible inside. A named volume is storage Docker creates and manages itself, in a location you don't need to know or care about, referenced only by name.
Bind mounts are the natural choice for local development: mount your source directory into the container and edits on your host machine show up instantly inside it, no rebuild needed. Named volumes are the better choice for anything you actually want to persist reliably in production — a database's data directory, for example — because Docker manages the lifecycle, they work consistently across host operating systems, and tools built around Docker (backup utilities, volume drivers) expect them.
docker run -v $(pwd):/app myimage # bind mount — host path in, for local devdocker run -v pgdata:/var/lib/postgresql/data postgres # named volume — for real persistenceThe default bridge and user-defined bridge networks only connect containers on a single Docker host. An overlay network extends that same idea across multiple hosts, letting containers on different machines talk to each other as if they were on one flat network — Docker handles the routing between hosts underneath.
You need one as soon as you move from a single-host setup to a multi-host one, most commonly with Docker Swarm: Swarm services on different nodes communicate over an overlay network by default, using the same service-name DNS resolution you get from a user-defined bridge, just extended across the cluster. Outside Swarm or Kubernetes (which has its own networking model), a single-host project almost never needs one — a user-defined bridge is enough.
BuildKit is Docker's modern build engine — the default since Docker 23.0 — that replaced the older, simpler builder. It parallelizes build steps that don't depend on each other, skips stages of a multi-stage build whose output nothing else needs, supports mounting build-time caches and secrets without baking them into layers, and gives clearer, more structured build output.
The secret-mounting piece is worth knowing specifically: RUN --mount=type=secret lets a build step use a credential (say, a private package-registry token) without that value ever landing in an image layer, which solves the exact "secrets end up in docker history" problem covered in the configuration question above — something the classic builder had no clean answer for.
# syntax=docker/dockerfile:1RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm installdocker build --secret id=npmrc,src=$HOME/.npmrc .docker exec starts a brand-new process inside an already-running container — most commonly docker exec -it mycontainer sh to get an interactive shell alongside whatever the container's main process is already doing. docker attach instead connects your terminal directly to the container's existing PID 1 process and its stdin/stdout/stderr streams.
The practical difference matters when things go wrong: exiting an exec session just ends that one extra process, leaving the container's main process untouched. Exiting an interactively-attached session, without being careful about detach keys, can send a signal to PID 1 itself and stop the container. For poking around inside a running container to debug something, exec is almost always what you actually want.
Three steps. Authenticate to the registry with docker login <registry-url>, supplying credentials or a token. Tag your local image so its name includes the registry's hostname — Docker uses that prefix to know where to send it — with docker tag localimage:tag registry.example.com/namespace/imagename:tag. Then push it with docker push registry.example.com/namespace/imagename:tag.
docker login registry.example.comdocker tag myapp:1.4 registry.example.com/team/myapp:1.4docker push registry.example.com/team/myapp:1.4A detail worth naming unprompted: without that registry-hostname prefix, docker push assumes Docker Hub, so a missing or wrong tag is the most common reason a push "succeeds" against the wrong destination — or fails outright with an auth error against a registry you never intended to reach.
A logging driver controls where a container's stdout/stderr output actually goes. The default, json-file, writes logs to a JSON file per container on the host, which is what docker logs reads from — fine for local development, but it grows without bound unless you configure log rotation, and it doesn't centralize logs across many hosts.
In production, teams commonly switch to a driver that ships logs somewhere centralized instead — syslog, journald, fluentd, awslogs, or gelf, among others — so logs from every container land in one searchable place rather than scattered across each host's local disk. The trade-off to mention: once you switch away from json-file, docker logs on that container often stops working locally, because the driver is sending output elsewhere instead of keeping it available for that command.
docker run --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3 myimagedocker run accepts flags for both. --memory (or -m) caps how much RAM the container can use, and --memory-swap controls the combined memory-plus-swap ceiling — setting them equal effectively disables swap for that container. --cpus caps how much CPU time the container can consume, expressed as a number of cores (--cpus=1.5 means one and a half cores' worth), and --cpu-shares sets a relative weight for CPU time when the host is under contention, rather than a hard cap.
docker run --memory=512m --memory-swap=512m --cpus=1.5 myimageWithout limits, a single misbehaving container can starve every other container — and the host itself — of memory or CPU. Setting them is also what turns a memory leak into a clean, diagnosable OOMKilled event (exit code 137, covered later) instead of the whole host grinding to a halt.
A tag, like myapp:1.4 or myapp:latest, is a human-readable, mutable label — someone can re-push a different image under the same tag at any time, and a pull the next day can silently return something different. A digest is a cryptographic hash of the image's content, written as myapp@sha256:abc123..., and it's immutable by construction: it can only ever refer to exactly one set of bytes.
Pulling or deploying by digest guarantees you get the exact image you tested, with no possibility of the "someone moved the tag" problem that makes latest risky. Tags stay useful for human-facing version numbers, but for anything where reproducibility genuinely matters — a production deployment manifest, a security audit trail — pinning by digest is the stronger guarantee.
docker pull myapp@sha256:9f6a4e1c...Docker Content Trust (DCT) adds cryptographic signing and verification to images, built on The Update Framework (TUF). When it's enabled (by setting DOCKER_CONTENT_TRUST=1), docker push signs the image you're publishing, and docker pull refuses to pull anything that isn't signed by a trusted key — it won't silently accept a tampered or unsigned image, even if it has the right name and tag.
The problem it solves is supply-chain trust: a tag by itself proves nothing about who actually built the image or whether it was altered after publishing. Signing gives you a verifiable link back to the publisher. It's one piece of a broader hardening checklist alongside vulnerability scanning and minimal base images — signing tells you the image is authentic, scanning tells you it's not carrying known CVEs, and neither one substitutes for the other.
docker-compose was a separate Python binary you installed on top of Docker. docker compose (no hyphen) is the current version — a Go-based plugin built into the Docker CLI itself, invoked as a subcommand rather than a standalone tool. Compose v2 is what current Docker installs and documents ship with, and Compose v1 stopped receiving updates.
Functionally the YAML format is almost entirely compatible, so most existing docker-compose.yml files work unchanged. The practical differences are speed (the Go implementation is noticeably faster on large stacks), a few new top-level features like profiles and depends_on health conditions that only exist in v2, and the command itself — scripts and CI pipelines still calling the hyphenated docker-compose binary need updating, or need it installed as a compatibility shim, since it isn't guaranteed to be present anymore.
docker compose version # v2, built into the Docker CLIdocker cp copies files directly between a container's filesystem and the host, one-off, without needing any mount configured ahead of time — useful for pulling a log file out of a container that's already running, or dropping a config file into one without restarting it. A volume or bind mount, by contrast, is a live, ongoing link between a host (or Docker-managed) location and a path inside the container, set up at container-creation time and staying in sync for as long as the container runs.
docker cp mycontainer:/var/log/app.log ./app.log # one-off copy outdocker cp ./config.yaml mycontainer:/app/config.yaml # one-off copy inUse docker cp for a quick, occasional grab or drop. Use a volume or bind mount for anything that needs to stay continuously accessible or persist past the container's life — docker cp doesn't survive a docker rm any better than the container's own writable layer does, since it just wrote into that same writable layer unless the destination path happens to be a mount.
A multi-stage build uses more than one FROM in a single Dockerfile. An early stage, built from a full toolchain image, compiles or builds your application. A later, much slimmer final stage copies only the finished artifact out of the build stage with COPY --from=builder, and that final stage is what actually becomes your image — the compilers, source code, and build dependencies from the earlier stage never ship.
The effect is not subtle. The same Go application, built two ways:
docker imagesREPOSITORY TAG SIZEdemo single 1.27GBdemo multi 15.5MBThe single-stage image carries the entire Go toolchain — well over a gigabyte of compilers nothing in production ever runs — while the multi-stage image is just an Alpine base plus a static binary. Smaller images mean faster pulls, faster deploys, a smaller attack surface, and less for a vulnerability scanner to flag.
# Stage 1: buildFROM golang:1.23 AS builderWORKDIR /appCOPY . .RUN CGO_ENABLED=0 go build -o server . # Stage 2: runFROM alpine:3.20COPY --from=builder /app/server /serverENTRYPOINT ["/server"]The common wrong answer here is treating multi-stage builds as purely a size optimization. The security angle is just as important: no compiler, no build cache, no leftover source files in the shipped image means less for an attacker to work with if they ever land inside a running container.
Not to the same degree, and being honest about that is exactly what separates a strong answer from a shaky one. Containers are isolated using kernel features, not a hardware boundary. Namespaces give each container its own view of the world — its own process tree, its own network interfaces, its own mount points, and (with user namespaces) its own notion of user IDs — so it can't see or touch what's outside that view by default. Cgroups cap what resources it's allowed to consume: CPU, memory, I/O, process count.
But every container on a host still shares that host's single kernel. A VM's hypervisor puts a much harder boundary between guests — each with its own kernel — than namespaces put between containers. That means a kernel vulnerability, or a container running with excessive privileges (--privileged, or extra capabilities it doesn't need), has a shorter path from "inside the container" to "on the host" than an equivalent bug would have against a VM.
The summary that lands well in an interview: containers isolate processes, VMs isolate kernels. That single distinction is also why running as non-root, dropping capabilities, and avoiding --privileged mode (the next question) matter so much more for containers than they typically do for VM-based workloads — the isolation layer is thinner, so the configuration on top of it has to do more work.
By default, a container's process runs as root — and inside the container's own user namespace, that root maps straight to UID 0 on the host kernel, since Docker doesn't remap user IDs unless you specifically configure it to. You can see the default directly:
docker run --rm busybox id# uid=0(root) gid=0(root) groups=0(root),10(wheel)The risk is what happens if an attacker finds a way to break out of the container — through a kernel bug, a misconfigured mount, or an overly generous capability. Because containers share the host kernel (see the isolation question above), escaping a root container puts an attacker much closer to root on the host than escaping a non-root one would.
The fix has two parts. First, add a USER instruction in the Dockerfile so the process runs unprivileged by default:
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroupUSER appuserdocker run --rm --user 1000:1000 busybox id# uid=1000 gid=1000 groups=1000Second, go further than just dropping to a non-root user: drop Linux capabilities you don't need (--cap-drop=ALL --cap-add=<only what's required>) and avoid --privileged mode entirely unless there's no alternative, since privileged mode disables most of the isolation namespaces provide in the first place. "Run as non-root by default, drop capabilities you don't use" is exactly the kind of practice that separates a candidate who's actually hardened a container in production from one who's only read about it.
Because the process inside the container never receives, or never handles, the SIGTERM that docker stop sends first — so Docker waits out the full grace period (10 seconds by default) and then falls back to SIGKILL. There are two usual causes, and naming both shows real depth on this question.
The shell-form problem. CMD myapp (shell form) gets silently wrapped as /bin/sh -c "myapp", which can leave a bare shell process as PID 1 instead of your application. A shell doesn't automatically forward SIGTERM to the child process it spawned, so the signal arrives at the shell and never reaches your app at all. (With a single simple command many shells will exec it, replacing themselves so your app does become PID 1 anyway — which is exactly why this bug feels inconsistent between Dockerfiles. A command chain or multiple statements keep the shell in front for real.) Switching to the exec form, CMD ["myapp"], makes your application PID 1 directly, so it receives the signal itself.
The app genuinely ignores SIGTERM. Some applications, or some language runtimes by default, don't register a SIGTERM handler at all, so even as PID 1 they just keep running until SIGKILL arrives.
The full fix: use the exec form so your process is PID 1, handle SIGTERM in your application code to shut down gracefully (close connections, flush buffers, then exit), and add a minimal init process — docker run --init, or tini inside the image — if your app spawns child processes of its own, since PID 1 also has to reap zombie processes and an init process handles that correctly where a bare application usually doesn't.
A candidate who connects "slow shutdown" straight to "PID 1 and signal handling," instead of just shrugging and calling it normal, is showing they've actually debugged this in production.
A minimal base image strips out everything a general-purpose Linux distribution normally ships with, keeping only what your application actually needs to run. Alpine is a common middle ground — a tiny distro with a package manager, so you can still install what you need. A distroless image goes further still, typically containing just your application's runtime and its direct dependencies, with no shell, no package manager, and no general-purpose utilities at all.
The benefit is two-fold: a smaller image (faster pulls, less storage), and a meaningfully smaller attack surface. No shell means an attacker who does land inside the container has almost no tools to work with — no sh to get a foothold, no package manager to pull in more. Fewer packages overall also means fewer CVEs for a vulnerability scanner to flag on every build.
The trade-off worth naming unprompted, because it's the part people forget: debugging gets harder. docker exec -it mycontainer sh simply doesn't work against a distroless image, because there's no shell to exec into. Teams that adopt distroless in production commonly keep a "debug" variant of the same image with a shell added back in, used only when actively troubleshooting, or lean on docker debug / ephemeral debug containers that attach a toolbox to a running pod or container without modifying the production image itself. It's the same underlying instinct as running non-root: minimize what's present, accept that it costs you some convenience.
Start by finding where the weight actually is, rather than guessing. docker history <image> lists every layer with its size, almost always pointing straight at the culprit — usually the base image itself or a dependency-install step that isn't cleaning up after itself.
docker history myapp:latestOnce you know what's heavy, work through the standard levers, roughly in order of impact. Move to a multi-stage build first if you aren't already using one — this alone commonly accounts for most of the savings, since it strips compilers and build-time dependencies out of the shipped image entirely. Switch to a slim or Alpine base image if the current one is a full general-purpose distro. Check that RUN steps clean up in the same layer they install in — running apt-get clean in a later instruction doesn't shrink the layer where the packages were originally installed, since that layer is already frozen. And confirm you actually have a .dockerignore, since a bloated build context slows down every single build, independent of the final image size.
For slow builds specifically (as distinct from large final images), also check instruction order against the build-cache rules — copying source before installing dependencies means every code change reinstalls everything from scratch, which is often the real reason a build "feels slow" even when the final image size looks reasonable.
The numbers aren't hypothetical: the same Go app dropped from 1.27GB to 15.5MB moving from a single-stage to a multi-stage build with an Alpine final stage — no other change needed.
Start with the two cheapest sources of information: docker ps -a to see the exit status, and docker logs <container> to see whatever the process printed before it died.
docker ps -a# NAMES STATUS COMMAND# myapp Exited (0) 2 seconds ago "python app.py"The most common cause of this, and the one that trips people up because it doesn't feel like a "bug" at all, is that the container's main process simply finished — exit code 0, nothing crashed — so the container stopped exactly as designed. A container only lives as long as its PID 1 process; there's no separate "container is still open" state once that process ends. This usually means the CMD ran a one-shot command instead of starting a long-running server, or a script that was supposed to launch something in the foreground actually backgrounded it and then exited.
A non-zero exit code points somewhere different — an actual crash, a missing dependency, a bad config file the app couldn't find, or a command that doesn't exist inside the image at all. docker logs will usually show a stack trace or an error message that makes the cause obvious. If the logs are empty and the exit happened instantly, it's worth checking that the entrypoint script itself is executable and has the right shebang line, since a permission error there can fail before the application ever gets a chance to log anything.
The debugging order that shows method, not luck: logs first, then the exit code and docker inspect for more detail, and only reach for an interactive shell (docker run -it --entrypoint sh myimage) if the first two don't explain it.
Almost always, the build cache served a stale layer, or you rebuilt correctly but ran the wrong image. Three specific things to check, in order.
The cache didn't actually re-run your COPY. Watch the build output for CACHED next to the step that copies your source in — if it's cached, Docker reused the old layer instead of picking up your new files. This usually traces back to instruction order: if something before your source COPY changed in a way Docker doesn't detect (a file modified outside the build context, or a RUN step whose command text didn't change but whose actual behavior did), the cache can serve stale results for everything downstream too.
docker build --no-cache -t myapp . # forces every layer to actually rebuildYou rebuilt but didn't retag, or the compose file references an older tag. If you're running docker compose up without --build, Compose may reuse an already-built image instead of rebuilding, especially if the image tag didn't change. docker compose up --build forces it.
You're running a stale container, not a stale image. A docker run from an old shell history, or a Compose service that wasn't recreated, can keep an old container alive even after a fresh image exists. docker compose up -d --build (or docker stop/docker rm followed by a fresh docker run) clears this up.
Nine times out of ten this is the build cache doing exactly what it's designed to do, just at a moment you didn't want it to — confirm with docker build --no-cache first, since that isolates whether it's a caching issue or something else (like a Compose stack not being recreated) entirely.
It was sent SIGKILL. 137 is 128 + 9, where 9 is the signal number for SIGKILL — Docker (and Linux generally) encodes a process killed by a signal as 128 plus that signal number. The usual cause inside a container is the out-of-memory killer: the container hit its configured memory limit, and the kernel killed the offending process to protect the rest of the system.
You can confirm this is what happened, rather than guessing, with docker inspect:
docker run --memory=20m --memory-swap=20m busybox sh -c 'tail /dev/zero'echo "exit: $?"# exit: 137 docker inspect <container> --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'# OOMKilled=true ExitCode=137OOMKilled=true is the confirming evidence — without it, a 137 could in principle come from something else sending SIGKILL manually, like docker kill or an orchestrator's own eviction logic, so don't assume OOM without checking. Once confirmed, the fix is one of two things: raise the memory limit if the workload genuinely needs more than it was given, or find and fix a memory leak if the limit was reasonable and usage climbed over time. docker stats while the container runs, or your orchestrator's metrics if it's Kubernetes, will show whether memory usage is flat (limit was just too low) or climbing (there's a leak).
Because that data was written to the container's writable layer, and the writable layer is destroyed the moment the container is removed — that's not a bug or a misconfiguration, it's exactly how container storage works by design. Nothing about docker rm is supposed to preserve it; a fresh container from the same image starts with a clean writable layer every time.
The fix is to route anything you actually want to keep to storage that lives outside the container's lifecycle: a named volume, or a bind mount to a host path. Either way, the data is written somewhere that survives the container being removed, and a new container mounting the same location picks up right where the old one left off.
docker run --rm -v mydata:/data busybox sh -c 'echo important > /data/file.txt'docker run --rm -v mydata:/data busybox cat /data/file.txt# important <- still there, because the container above was removed, not the volumeThe one-line rule to carry into an interview: if it needs to survive the container, it doesn't belong in the container's own filesystem. Anything stateful — a database's data directory, uploaded files, generated reports someone needs later — goes on a volume or bind mount from the start, not as an afterthought once data has already been lost once.
Both orchestrate containers across multiple hosts — scheduling, scaling, service discovery, rolling updates, self-healing — but they sit at very different points on the complexity-versus-capability curve, and the honest answer to this question is "it depends on the team and the workload," not a blanket preference.
Docker Swarm is built directly into the Docker Engine and CLI, so there's effectively nothing extra to install: docker swarm init and you have a cluster, using the same commands and mental model as single-host Docker, over an overlay network by default. That makes it a reasonable fit for a small team, a simpler application topology, or anyone who wants multi-host resilience without taking on a second platform's worth of concepts and YAML.
Kubernetes is far more capable but also far more complex: a much richer scheduling model, a huge ecosystem of controllers and operators, fine-grained networking policies, and the ability to handle large, intricate multi-service systems that Swarm's simpler model isn't really built for. That capability comes at a real cost in operational complexity and learning curve, which is exactly why Swarm still has a niche.
In practice, most new production systems past a certain size land on Kubernetes, because the ecosystem and hiring pool have consolidated around it — but a candidate who can articulate why a small, simple deployment might genuinely be better served by Swarm's lower complexity, instead of reflexively saying "always use Kubernetes," is showing they understand the trade-off rather than just repeating the popular answer.
Plain Docker on a single host has no built-in rolling-update mechanism — that's one of the capabilities an orchestrator like Swarm or Kubernetes exists to provide — so a zero-downtime deploy has to be assembled from a few pieces, and being upfront about that trade-off is part of a good answer.
The common pattern is blue-green at the single-host level: run the new version of the container alongside the old one on a different port, wait for its health check to pass, then flip a reverse proxy (nginx, Traefik, Caddy) in front of both to point at the new container, and only stop the old one once traffic has fully moved over.
docker run -d --name app-green -p 8081:8080 myapp:1.5# wait for app-green's health check to report healthy# update the reverse proxy's upstream from :8080 (blue) to :8081 (green)docker stop app-blueA simpler variant some teams use is docker run --restart=always behind a proxy with a short health-check interval, accepting a few seconds of downtime during the swap in exchange for far less machinery. The honest framing for an interview: this works and is a legitimate pattern for a single host or a small deployment, but it's also exactly the kind of coordination — health checks, traffic shifting, rollback on failure — that Swarm's docker service update or Kubernetes' rolling updates handle for you automatically once you have more than one host or more than a couple of services to manage.
Work outward from the process, in a specific order, rather than guessing at the network first — the KodeKloud framing of "logs first, network second, shell third" is a good one to state out loud, since it shows a method rather than a lucky guess.
Logs first. docker logs <container> — is the application even logging requests coming in? If nothing shows up at all, the request may not be reaching the container.
Then the network. Confirm the port mapping actually exists and matches what you expect (docker port <container>), and that the application inside is listening on the interface you think it is — a very common trap is an app bound to 127.0.0.1 inside the container instead of 0.0.0.0, which makes it unreachable from outside the container's own network namespace even though docker ps shows the port published correctly.
docker port mycontainerdocker exec mycontainer netstat -tlnp # or ss -tlnp — confirm what's actually listening, and on what interfaceThen get a shell. docker exec -it <container> sh and try hitting the app from inside its own network namespace with curl localhost:<port> — if that works but the same request fails from the host, the problem is squarely in networking or the port mapping, not the application. If it fails even from inside, the application itself is stuck, and it's time to look at its own health (deadlock, exhausted connection pool, stuck on a dependency it's waiting for).
Resource limits last. docker stats to rule out the container being CPU- or memory-starved to the point where it's technically alive but too slow to respond.
The value of working in this order is that each step narrows the search space for the next one, instead of jumping straight to "restart it and hope," which fixes the symptom without ever explaining what actually went wrong.