Docker jumped 17 percentage points in year-over-year usage in the 2025 Stack Overflow Developer Survey, the single biggest jump of any technology measured, landing at 71 percent among professional developers (Stack Overflow, 2025). That kind of adoption curve is exactly why Docker interview questions have gotten more specific in 2026. Interviewers assume baseline fluency now and spend the actual interview time on failure modes, not definitions.
Here's an opinion that might be wrong: I think most Docker interview prep still treats the whole subject like a vocabulary quiz, define an image, define a volume, list the CMD instructions, and skips the part that actually separates a junior candidate from someone who's shipped containers to production. Almost nobody gets tripped up defining COPY. They get tripped up explaining why a build that worked fine yesterday suddenly reruns nine layers after a one-line change to a file the app doesn't even read at runtime.
This page covers the Docker interview questions that come up across backend, DevOps, and platform engineering loops in 2026: images versus containers, Dockerfile instructions, layer caching, multi-stage builds, volumes, networking, Compose, registries, security basics, and the failure states everyone eventually runs into. Kubernetes gets its own separate treatment since most loops that go deep on one rarely go deep on both in the same round.
Images and containers: the distinction interviewers actually check
This is the warm-up every loop opens with, junior or senior. A sloppy answer doesn't fail you outright, but it tells the interviewer what kind of interview to run for the next forty minutes.
Easy questions
17An image is a read-only, layered filesystem plus metadata, the packaged recipe for a piece of software and everything it needs to run. A container is a running (or stopped) instance of that image: the same image plus a thin writable layer on top, plus an isolated process tree.
You can start ten containers from one image, and none share a writable layer, so changes in one never leak into another or back into the image itself. Interviewers ask this to catch candidates who say "I built a container" when they mean "I built an image," a small slip that tells you whether the mental model is straight.
A container is just a regular process on the host, isolated with Linux namespaces (PID, network, mount, and a few others) and resource-limited with cgroups. It shares the host's kernel the entire time it runs. A VM virtualizes hardware and boots an entirely separate kernel.
That's why a container starts in milliseconds and a VM takes seconds to minutes, there's no OS to boot, just a process getting namespaced. It also means a Linux container can't run natively on a Windows kernel without a VM underneath it, exactly what Docker Desktop quietly does on Mac and Windows. Interviewers use this to see where the "lightweight VM" analogy actually breaks.
$ docker run -d --name test alpine sleep 300
$ docker exec test touch /tmp/marker
$ docker stop test
$ docker start test
$ docker exec test ls /tmp/marker
/tmp/markerdocker run creates a brand new container from an image and then starts it, in one step. docker start only works on a container that already exists, stopped or exited, and starts that exact same container again, same writable layer, same filesystem changes it had when it stopped.
Run docker run twice with identical flags and you get two separate containers, each with its own writable layer. Run docker start on the same container ID twice in a row and the second call is a no-op, or an error if it's already running. Interviewers ask this because candidates who only ever docker run in local dev often don't realize a stopped container's filesystem changes stick around until it's actually removed with docker rm.
COPY does exactly one thing: it copies files or directories from the build context into the image. ADD does that too, but also auto-extracts local tar archives and can fetch a file from a remote URL.
Default to COPY. The Dockerfile reference recommends it for anything that isn't tar extraction, because ADD's extra behavior triggers by accident, a filename that looks like a tarball gets silently unpacked instead of copied as-is. Reach for ADD only when you need the auto-extract behavior, and never for a remote URL. A RUN curl gives you a visible, cacheable step instead of a hidden fetch buried in the instruction.
ARG is a build-time-only variable, available while the image is being built and gone the moment the build finishes. It never exists inside a running container. ENV sets an environment variable that's baked into the image and available both during the rest of the build and in every container started from it.
A common pattern combines both: declare ARG VERSION=1.0 then ENV APP_VERSION=$VERSION to carry that value into the runtime environment. Skip the ENV step, and anything expecting the version inside the running container gets nothing, since ARG values don't survive past the build stage that declared them.
Each RUN instruction executes in its own shell, so RUN cd /app && npm install works fine, but a bare RUN cd /app followed by a separate RUN npm install does nothing useful. The second RUN starts a fresh shell back at the image's default working directory, not wherever the previous RUN happened to cd into.
WORKDIR sets the working directory for every instruction that comes after it, COPY, RUN, CMD, ENTRYPOINT, all of it, and it persists for the rest of the build and into the running container. It also creates the directory if it doesn't already exist, one less mkdir to remember. Using WORKDIR /app once at the top beats sprinkling cd into individual RUN lines and hoping every author of every future line remembers to do the same thing.
LABEL org.opencontainers.image.source="https://github.com/acme/api"
LABEL org.opencontainers.image.version="2.4.1"
LABEL maintainer="platform-team@acme.com"LABEL attaches arbitrary key-value metadata to an image, baked into the image manifest itself, not something that affects how the container runs. docker inspect and docker image ls --filter can both read it back.
The practical use is tracking provenance at scale. Once a registry holds a few hundred images, LABEL fields like a Git commit SHA or a build date let you answer "which image is actually running in production right now" without redeploying anything just to check, and tools like Trivy and the OpenContainers spec both expect a small set of standard label keys for exactly this reason.
A bind mount, -v /host/path:/container/path, points directly at a path on the host filesystem, whatever's already there. A named volume, -v my-app-data:/container/path, is managed entirely by Docker, and Docker decides where it physically lives.
Bind mounts are great for local development, edit code on the host and see it reflected instantly inside the container, but they tie the container to that exact host path, which breaks the moment you move to a different machine. Named volumes are the portable, production-friendlier option because the container never needs to know or care where the data physically sits.
The default bridge network. Every container gets its own network namespace and a private IP on an internal subnet, NAT'd through the host so outbound traffic works, and you explicitly map ports, -p 8080:80, to expose anything inbound.
One catch: containers on the default bridge network can reach each other by IP, but not by name, there's no built-in DNS on that specific network. Name-based resolution only shows up on a user-defined bridge network, part of why Compose, covered below, puts every service on its own network automatically.
# Explicit mapping: host port 8080 goes to container port 80
docker run -p 8080:80 nginx
# -P: publish every EXPOSEd port to a random free host port
docker run -P nginx-p host:container maps one specific host port to one specific container port, you choose both sides. -P (capital) publishes every port the image's Dockerfile marked with EXPOSE, but to a random available host port Docker picks for you, not a port you control.
-P is mostly useful for quick local testing when you don't care which host port you land on and just want to run docker port afterward to find out. Anything that needs a predictable, documented port, which is almost every real deployment, uses explicit -p mappings instead.
The second container fails to start, with an error along the lines of "port is already allocated." A host port can only be bound by one process at a time, container or not, the same rule that applies to any two regular processes fighting over the same port.
The fix is either mapping each container to a different host port, -p 8080:80 and -p 8081:80, or putting a reverse proxy in front of both that listens on 80 itself and routes by hostname or path to each container's own internal port. The second pattern is what most production setups actually do, since it also means adding a third service later doesn't require juggling host ports at all.
Compose lets you declare an entire multi-container setup, services, networks, volumes, environment variables, dependencies, in one YAML file, then bring it all up or down with a single command instead of re-typing a growing pile of docker run flags correctly every time.
It also automatically creates a user-defined network for the project, which is why services in a compose file can already reach each other by name, tying back to the DNS question above, without any manual docker network create step.
docker compose up reuses an existing image for a service if one's already built and tagged for it, and only triggers a build if no image exists yet. --build forces every service with a build key to rebuild regardless of whether an image already exists.
The trap: edit your Dockerfile or source code, run plain up again, and Compose happily starts containers off the stale image because it never checked whether the build inputs changed, only whether an image with that tag already exists. --build, or deleting the stale image, is the fix. It's a question that catches people who've only ever used Compose for local dev, where they habitually run --build out of muscle memory without knowing exactly why.
Functionally, both speak the same registry API, docker push and docker pull work identically against either. The real differences are access control and cost. Docker Hub's free tier has public repos plus rate-limited pulls, while cloud-provider registries integrate directly with that cloud's IAM, so you grant pull access through the same permission system managing everything else in the account.
The tag format gives it away in practice. A Docker Hub image is just myimage:tag, but an ECR image is the full registry hostname, something like 123456789.dkr.ecr.us-east-1.amazonaws.com/myimage:tag, and you have to docker login against that specific registry hostname before a push or pull against it will work.
Without one, COPY . . sends everything in the build directory to the daemon as build context, including .git, local .env files, and anything else sitting in the folder, whether or not a later instruction ever references it directly.
The build-context-size problem is real but minor. The sharper risk is a secret copied into an early layer and then "deleted" in a later one. Layers are immutable and additive, so deleting a file in layer 5 doesn't remove it from layer 2, it's still sitting in the image, extractable with docker save or docker history. .dockerignore stops the secret from entering any layer in the first place, the only version of "removed" that actually works here.
:latest is just a tag like any other, it has no special meaning to Docker beyond being the default when no tag is specified. The problem is that it's mutable. Someone pushes a new build as :latest next week, and every environment still referencing :latest silently starts pulling a different image the next time it restarts, with no record in your deploy history of which actual build is running where.
A pinned version tag, myapp:2.4.1, or better, a digest, makes exactly what's running fully traceable, roll back to a known-good build means redeploying that same tag or digest, not guessing which past :latest push was the good one. Reserve :latest for local experimentation, never for anything you'd need to explain a rollback for at 2am.
$ docker logs my-container # works fine even if it already stopped
$ docker logs --since 10m my-container
$ docker logs -f my-container # follow, only useful while still runningdocker logs reads from the container's log storage on disk, not from a live process, so it works identically whether the container is running, stopped, or crashed, as long as the container itself hasn't been removed with docker rm. Remove the container and its logs go with it, which is the actual gotcha, not the running-vs-stopped distinction people usually assume.
That's the whole reason production setups ship logs somewhere outside the container's own lifecycle entirely, a log aggregator, a centralized logging driver, before anyone runs docker rm or a deploy replaces the container. Debugging a crash on a container that's already been cleaned up by a restart policy or an orchestrator means the local docker logs history is already gone, and the aggregator is the only place left to look.
Medium questions
26ENTRYPOINT sets the fixed command that always runs. CMD sets default arguments, ones that get overridden the moment someone appends arguments to docker run.
Set both, and CMD's contents get passed as arguments to ENTRYPOINT instead of running standalone. ENTRYPOINT ["python", "app.py"] with CMD ["--debug"] runs python app.py --debug by default, and docker run myimage --port 9000 runs python app.py --port 9000 instead. CMD's arguments get replaced entirely, not appended to. Candidates who've only ever used CMD alone usually guess wrong here on the first try, which is exactly why it gets asked.
# Bad: update gets cached separately from install
RUN apt-get update
RUN apt-get install -y curl
# Better: one layer, so the cache can't go stale between the two
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*Split across two RUN instructions, Docker caches the update layer independently. Rebuild the image weeks later with an unchanged Dockerfile above that line, and the cached update layer gets reused, pointing at package index metadata that's now stale, while the install layer pulls whatever's currently available. You can end up installing a package that update layer never actually indexed.
Chaining them with && forces both to happen together or not at all, so the cache invalidates as a unit. Appending rm -rf /var/lib/apt/lists/* in the same instruction is the other half of the pattern, deleting it in a later RUN doesn't shrink the image at all, since the earlier layer already has that data baked in permanently.
# Slow: any source change reinstalls every dependency
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]Each instruction that touches the filesystem produces its own layer, stacked read-only on top of the ones before it. Docker caches each layer and reuses it on the next build if the instruction and everything above it are unchanged.
# Fast: dependency layer only rebuilds when package.json changes
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]Copy the whole app before running npm install, and any source file change reruns the install step, since Docker hashes the copied content and a changed hash breaks the cache from that instruction downward. Copy just the dependency manifests first, install, then copy the rest of the source, and editing a source file only invalidates the cheap final COPY, not the expensive install.
$ docker history myimage:latest
IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5f6 3 minutes ago CMD ["node" "server.js"] 0B
f6e5d4c3b2a1 3 minutes ago COPY . . 4.2MB
...docker history lists every layer in an image, the instruction that produced it, and how many bytes it added, oldest first. It's the fastest way to spot an obviously bloated layer, a RUN that installed 400MB of build tools that never got removed, for instance, without needing a third-party tool at all.
For anything past a quick check, dive (a standalone open source tool, not a Docker built-in) walks each layer interactively and shows exactly which files got added, modified, or wasted space by getting deleted in a later layer without ever shrinking the image. I reach for docker history first and only pull out dive once history tells me something's off but not exactly what.
# Stage 1: build with the full toolchain
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app/server .
# Stage 2: ship only the compiled binary
FROM alpine:3.20
COPY --from=builder /app/server /usr/local/bin/server
CMD ["server"]A single-stage build has to include the entire compiler, build dependencies, and intermediate artifacts in the final image, even though none of that is needed once the binary exists. A multi-stage build runs the compile step in one throwaway stage and copies only the finished artifact into a clean, minimal final stage (Docker Docs, Multi-Stage Builds).
The Go example above ships a base Alpine image plus one binary, not a full Go toolchain, source tree, and module cache that add hundreds of megabytes and a wider attack surface for no runtime benefit at all.
Yes, COPY --from=builder (or --from=0 by index) pulls files from any earlier stage, named or not, into the current one. Stages don't have to be linear either, a later stage can pull from any prior stage, not just the immediately preceding one.
This is genuinely useful for splitting concerns further than build-vs-runtime: a deps stage that installs and caches dependencies, a test stage that runs off deps and never gets copied into the final image, and a runtime stage that only pulls the compiled output. CI can target the test stage directly with docker build --target test and skip the runtime stage entirely for a pure test run.
No, and conflating them is a common mistake. A smaller base image shrinks the runtime layer you start from. A multi-stage build controls what makes it into the final image regardless of base, keeping build tools and intermediate files out entirely.
They're complementary, not substitutes. You can absolutely combine an Alpine final stage with a multi-stage build, that's the example above, and get both benefits stacked. My honest opinion: teams that only switch base images to alpine without restructuring into stages are solving maybe a third of the actual image bloat problem. The remaining build tooling and source tree are usually the bigger chunk once you actually measure it.
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app/server .
# Pull a healthcheck binary from a totally unrelated public image
COPY --from=curlimages/curl:8.7.1 /usr/bin/curl /usr/local/bin/curl
FROM alpine:3.20
COPY --from=builder /app/server /usr/local/bin/server
COPY --from=builder /usr/local/bin/curl /usr/local/bin/curl
CMD ["server"]Yes. COPY --from accepts a named build stage, but it also accepts any image reference at all, including one that never gets built as part of this Dockerfile. Docker pulls that image, or reuses it if it's already cached locally, and copies from its filesystem exactly like it would from an earlier stage.
This shows up in practice when a final image needs one small binary from somewhere else, a healthcheck tool, a static curl build for scratch-based images that ship no shell or package manager at all, without pulling in that image's entire toolchain. It's a small trick, but it's the kind of thing that separates someone who's only ever copied between their own named stages from someone who's actually optimized a production image down to the last unnecessary megabyte.
On a native Linux host, under /var/lib/docker/volumes/my-app-data/_data. docker volume inspect my-app-data prints the exact mountpoint if you don't want to guess.
On Docker Desktop for Mac or Windows, that path lives inside the Linux VM Docker Desktop runs under the hood, not browsable from Finder or Explorer at all, which trips people up the first time they go looking. Ties back to the container-vs-VM question from the top of this page: Docker Desktop runs a small Linux VM to give containers a real kernel, and volumes live inside that VM's filesystem, not yours.
FROM postgres:16
VOLUME /var/lib/postgresql/dataVOLUME marks a path inside the image as one that should always be a volume, an anonymous one gets created automatically at container start if nothing else was explicitly mounted there. It's Docker's way of saying "don't bake this directory's contents into the image layer, treat it as external data" even if the person running the container forgets to mount anything themselves.
The catch is that it's easy to lose track of anonymous volumes. Run the image ten times without an explicit -v flag and you get ten different anonymous volumes nobody's referencing by name, quietly consuming disk space that docker volume prune eventually has to clean up. Most teams skip VOLUME in their own Dockerfiles and instead document the path in a README, letting whoever runs the container decide explicitly whether to bind-mount it, name a volume for it, or just accept ephemeral storage on purpose.
# Back up: mount the volume read-only plus a host directory, tar it
docker run --rm -v my-app-data:/data:ro -v $(pwd):/backup alpine \
tar czf /backup/data-backup.tar.gz -C /data .
# Restore: reverse the flags, extract into the volume
docker run --rm -v my-app-data:/data -v $(pwd):/backup alpine \
tar xzf /backup/data-backup.tar.gz -C /dataThere's no docker volume backup command. The standard pattern spins up a disposable, otherwise-unrelated container, alpine works fine since it's tiny, mounts the named volume at one path and a host directory at another, then runs a plain tar command to archive one into the other.
The same pattern works for migrating a volume's data between two machines entirely, tar it on the source host, copy the archive over, extract it into a freshly created volume on the destination. It's low-tech on purpose. Volumes are just directories under the hood, so anything that can read and write files can back one up, no Docker-specific tooling required at all.
Host networking removes network isolation entirely. The container shares the host's network namespace directly, so a service listening on port 8080 inside the container is just listening on port 8080 on the host, no NAT, no port mapping needed or possible.
It's a real option when you need the absolute lowest network overhead, a proxy or load balancer handling heavy traffic, say. The trade-off: you lose port mapping, -p does nothing under host mode, and you lose isolation from every other process on the host's network stack, one container binding to a port the host already uses just fails to start. It also only works as described on native Linux hosts. Docker Desktop on Mac and Windows runs containers inside a VM, so "host" there means the VM's network, not your laptop's.
Docker runs an embedded DNS server at 127.0.0.11 inside every user-defined network, and it resolves each container's name (or its Compose service name) to whatever IP that container currently has. Containers query it automatically, nothing extra to configure.
That's the mechanism behind a Compose service reaching another one with a plain hostname like db or redis instead of a hardcoded IP that changes every time a container restarts. It only works on user-defined networks, not the legacy default bridge, which is the other half of the answer to the question above.
EXPOSE is documentation, not enforcement. It tells anyone reading the Dockerfile, and tools like docker run -P, which port the containerized application listens on, but it doesn't open any port to the outside world by itself. Skip it entirely and -p 8080:80 still works exactly the same, since the actual publishing behavior comes from the -p flag at runtime, not from anything declared at build time.
The confusion trips people up because it reads like a firewall rule and it just isn't one. A container with no EXPOSE line and no -p flag can still be reached by other containers on the same user-defined network, on whatever port it actually listens on, EXPOSE or not. It only changes behavior for -P and for tools that introspect the image to guess which port to map.
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
api:
build: .
depends_on:
db:
condition: service_healthyPlain depends_on: [db] only guarantees Docker starts the db container before the api container. It says nothing about whether Postgres has actually finished initializing and is accepting connections yet. An app that connects to the database in its own startup code often crashes on the very first boot, especially the first time a fresh volume needs Postgres to run its initialization scripts.
The fix is a healthcheck on the database service plus condition: service_healthy on the dependency, shown above, so Compose waits for Postgres to report healthy, not just running, before starting the API. The app should still retry its own connection on top of this, network blips happen, but the healthcheck removes the most common "it works if I restart it once" bug people blame on Docker instead of the missing readiness check.
Highest to lowest precedence: a value set directly under a service's environment: key in the compose file wins over everything. Below that, a variable loaded from env_file: sets the value if environment didn't already override it. Below that, the ENV instruction baked into the image at build time only applies if nothing at the Compose level touched that variable name at all.
Shell variables are a separate case: they're used for substitution inside the compose file itself before it's parsed into service definitions, not a fourth layer competing at container-start time. Mixing all four in one service and expecting the obvious one to win is a fast way to burn an hour debugging a value that looks right everywhere except inside the running container.
# docker-compose.yml (checked into git, shared by the whole team)
services:
api:
build: .
environment:
NODE_ENV: production
# docker-compose.override.yml (gitignored, personal to one machine)
services:
api:
environment:
NODE_ENV: development
volumes:
- ./src:/app/srcCompose automatically merges docker-compose.override.yml on top of docker-compose.yml if the override file exists in the same directory, no extra flag needed. Keys in the override file win, and it's meant for exactly the kind of per-developer tweaks that shouldn't live in the shared file, a bind mount to your local source tree for live reload, or a different environment variable for local debugging.
The common pattern gitignores the override file entirely, so the base compose file stays clean and identical for everyone while each developer's local overrides never get committed by accident. It's the same idea as a local .env file that isn't checked in, just applied to service definitions instead of environment variables.
services:
api:
build: .
ports:
- "8080:8080" # fixed host port breaks scaling past 1A fixed host port mapping like 8080:8080 can only be bound once, exactly the same port conflict from the networking section above, just triggered by Compose's own scaling flag instead of two separate docker run commands. The first replica grabs port 8080, and the second one fails immediately trying to grab the same host port.
Fixing it means dropping the fixed host port and either letting Docker assign a random one, "8080" with no host-side number, or putting a load balancer in front that Compose itself doesn't manage and routing to each replica by its container-network address instead of a host port at all. Scaling a service and binding it to one fixed host port are two ideas that work against each other from the start.
A container's root user is the same UID 0 the host kernel knows, containers isolate namespaces, not the underlying user ID space, unless you specifically configure user namespace remapping. If an attacker breaks out of the container through a kernel exploit or a misconfigured mount, root inside the container often means root on the host, not a sandboxed nobody.
A 2021 Sysdig scan of public container images found 58 percent ran as root by default (Sysdig Container Security and Usage Report). That number's a few years old, and I couldn't find a clean 2026 refresh, but I'd bet it hasn't moved much. Fixing it just means adding a USER instruction and confirming nothing breaks, a step that gets skipped under deadline more than anyone admits. Add USER node (or any non-root UID) near the end of the Dockerfile, after any setup that genuinely needs root, like installing system packages.
Known CVEs baked into things your own diff never touches: the base OS image's system packages, and every transitive dependency your package manager pulled in without anyone on the team reading its source. A code review looks at the lines your team wrote. A scanner, Trivy, Grype, Docker Scout, and others all do roughly the same job, walks every installed package in every layer and checks it against public vulnerability databases.
The common miss teams make is scanning the Dockerfile once at merge time and never again. A base image with zero known CVEs today can have three disclosed against it next month without your Dockerfile changing a single character, so scanning needs to run on a schedule against images already sitting in the registry, not only at build time.
$ docker pull myapp:2.4.1
$ docker inspect myapp:2.4.1 --format '{{index .RepoDigests 0}}'
myapp@sha256:8f3a9c2b1e7d4f6a5c9b8e2d1f0a3c7b6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1bA tag, 2.4.1, is a mutable, human-readable pointer that a registry maps to whatever content hash it currently points at. A digest, sha256:8f3a..., is a content hash of the image manifest itself, immutable by definition, since changing a single byte of the image produces a completely different hash.
Pin to a digest anywhere reproducibility actually matters, a security-sensitive deploy pipeline, or a compliance requirement to prove exactly which bytes ran in production on a given date. A tag can theoretically get overwritten and repointed at different content later, even a version-numbered one, if someone force-pushes over it. A digest physically can't be, which is the whole reason it exists as a separate concept.
Alpine is still a real, if minimal, Linux distribution, it has a package manager (apk), a shell, and standard Unix utilities, all of which add both size and a small attack surface. A distroless image, Google's gcr.io/distroless family is the common one, strips even further: no shell, no package manager, no shell utilities at all, just the language runtime and your application's compiled output or interpreted files.
That's a real security win since there's no shell for an attacker to exec into even if they find a way to run arbitrary commands inside the container, but it's also the reason docker exec -it mycontainer sh fails outright on a distroless image, the same failure mode covered in the troubleshooting section further down this page. Distroless makes sense for a final runtime stage in a multi-stage build, it makes very little sense as a build-stage base image, since you'd have no shell to run your own build commands in during the build itself.
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1They're the same underlying mechanism, Compose's healthcheck: key just lets you set or override the same fields, test, interval, timeout, retries, from the compose file instead of baking them permanently into the Dockerfile. Either way, Docker runs the check command on a schedule and marks the container healthy, unhealthy, or starting based on the results, visible in docker ps right next to the container's status.
Baking it into the Dockerfile means every consumer of the image gets the same healthcheck by default, useful for a shared internal base image where you don't want fifteen different teams each guessing at their own health endpoint convention. Setting it in Compose instead is better when different environments genuinely need different check intervals, a slower healthcheck interval in a resource-constrained dev environment versus a tighter one in production, say.
docker run --memory=512m --memory-swap=512m myappNo --memory flag means no ceiling at all, the container can allocate host memory right up until the host itself runs out, at which point the kernel's OOM killer steps in and picks a process to kill, and it doesn't always pick the process actually responsible for the leak. One noisy container without a memory limit can take down every other container on the same host by starving them all of memory at once, exactly the shared-host risk from the OOM troubleshooting question earlier on this page.
Setting --memory gives that container its own ceiling, so a memory leak in one service kills only that one container, cleanly, with an exit code your monitoring can catch, instead of destabilizing the whole host. The harder part is picking the right number: too tight and a perfectly healthy app gets OOM-killed under normal peak load, too loose and you've mostly just delayed the same problem.
docker run --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3 myapp
docker run --log-driver=journald myappThe default json-file driver writes each log line as a JSON object to a file under /var/lib/docker/containers/<id>/, on the host's own disk, and docker logs just reads that file back. Nothing rotates or ships it anywhere else unless you set max-size and max-file yourself, which is why plenty of hosts eventually discover a container quietly wrote tens of gigabytes of logs to disk over months with zero rotation configured.
Switching to journald sends logs to the host's systemd journal instead, letting the host's own existing log rotation and querying tools handle it. Other drivers, syslog, gelf, awslogs, ship logs straight to an external system instead of the local disk at all. The trade-off is that docker logs stops working, or works only partially, once logs aren't stored where Docker expects to find them, so switching drivers usually means also switching how the team actually tails logs day to day.
Exit code 0 means the container's main process finished normally, it's not a crash at all, Docker did exactly what you told it to. The usual cause: the process you ran isn't actually a long-running foreground process. A base ubuntu image with no CMD override just runs bash, and bash with no attached terminal and nothing piped into it exits almost instantly.
docker ps -a confirms the exit code and how long it ran for, and docker logs my-container shows whatever it printed before exiting. If both point to "nothing crashed, it just finished," the real question is whether the intended process actually stays in the foreground. A script that backgrounds its own main process with & and returns immediately produces this exact symptom, and PID 1 exiting is what ends the container regardless of anything still running in the background behind it.
Hard questions
12Shell form, CMD npm start, actually runs as /bin/sh -c "npm start". Your app isn't PID 1 inside the container, the shell is, and the shell doesn't forward signals like SIGTERM to the child process it spawned. Exec form, CMD ["npm", "start"], runs the process directly as PID 1, no shell in between.
# Shell form: npm becomes a child of /bin/sh, not PID 1
CMD npm start
# Exec form: npm start runs directly as PID 1
CMD ["npm", "start"]The practical consequence: docker stop sends SIGTERM and waits ten seconds before sending SIGKILL. With shell form, that SIGTERM often just kills the shell, and the actual app process gets orphaned and hard-killed once the grace period runs out, no clean shutdown, no chance to drain in-flight requests. I've seen this exact bug cause dropped requests during rolling deploys on a service that "worked fine" for months, because nobody ever restarted it gracefully until a deploy pipeline started running rolling docker stop calls against it.
# Risky: the key ends up in image history even though ARG "expires"
ARG API_KEY
RUN curl -H "Authorization: Bearer $API_KEY" https://internal.example.com/setup
# Safer: BuildKit secret mount, never written to a layer
RUN --mount=type=secret,id=api_key \
curl -H "Authorization: Bearer $(cat /run/secrets/api_key)" https://internal.example.com/setupARG values don't persist into the running container, but they absolutely do persist in the image's build history. docker history --no-trunc on the risky version above prints the API key in plain text, and anyone who ever pulls that image can extract it, whether or not it's in the final running environment. The value gets baked into the layer the instant that RUN executes.
BuildKit's --mount=type=secret fixes this properly: the secret is mounted into the container filesystem only for the duration of that one RUN instruction and never gets written to a layer at all. Docker's own Compose and buildx tooling both support it directly now, so there's not much excuse left for the ARG-as-secret pattern in anything built after 2023 or so.
Because the cache is chained, not independent per instruction. Docker's own documentation puts it plainly: if a layer changes, every layer after it is affected too, whether or not that specific instruction would have produced different output (Docker Docs, Build Cache).
That's not a bug, it's the point of a content-addressed build, checking every downstream layer independently for whether it'd actually differ would mean re-executing most of them anyway just to find out. The practical lesson ties back to the question above: order your Dockerfile so volatile things, your source code, come after stable things, dependency manifests, base image, system packages, so a cache miss lands as late as possible in the chain.
# syntax=docker/dockerfile:1
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm install
COPY . .
CMD ["node", "server.js"]The regular build cache reuses a whole layer verbatim or throws it away. A cache mount, available with BuildKit, mounts a persistent directory into the container during just that one RUN instruction, surviving across builds even when the layer itself gets rebuilt.
That matters for package manager caches specifically. Even when package.json changes and the install layer's own cache misses, npm's download cache at /root/.npm is still sitting there from the last build, so npm install only fetches whatever actually changed instead of redownloading every dependency. It's a genuinely different mechanism from layer caching, not just a faster version of it, and a big part of why CI Docker builds got noticeably faster once BuildKit became the default builder.
docker build --cache-from myregistry/myapp:cache -t myapp:latest .
docker push myregistry/myapp:cacheOn a laptop, Docker's build cache lives on that one machine's disk and just sits there between builds. A CI runner is usually a fresh, disposable machine every single run, so there's no local cache to reuse at all, every build starts cold and reinstalls every dependency from scratch.
--cache-from tells Docker to pull a previously pushed image and treat its layers as cache candidates before building, so a CI job on a brand new runner can still skip the npm install layer if package.json hasn't changed since the last build that got pushed. It's the CI equivalent of the local build cache, just backed by a registry instead of a local disk. BuildKit's --cache-to/--cache-from with type=registry is the modern version of this same idea and handles it more efficiently than tagging a whole image as a cache source.
BuildKit (the default builder since Docker 23) can build independent stages in parallel automatically, if stage B doesn't COPY --from stage A, there's no dependency forcing them to run sequentially, and BuildKit's scheduler figures that out on its own without any extra syntax. The classic builder ran everything sequentially regardless, which is one of the quieter reasons BuildKit builds often feel faster even before you touch cache mounts.
Instruction order inside a single stage still matters exactly as much as it does in a single-stage build, the caching rules from earlier on this page apply per stage, not globally. What changes with multiple stages is that a cache miss in one stage doesn't necessarily force a rebuild of an unrelated stage that doesn't depend on it, which is a real advantage a single-stage Dockerfile never gets.
Generally, no, not for anything with its own on-disk format expecting a single writer, which describes most relational databases. Two Postgres or MySQL processes pointed at the same data directory will corrupt it, since the database engine assumes exclusive ownership of its files and locks, and Docker sharing a volume between containers does nothing to coordinate that at the application layer.
It's fine, sometimes even the point, for volumes genuinely designed for concurrent access, a shared uploads directory read by multiple stateless app containers, for instance. The question interviewers are really asking is whether a candidate reaches for "just mount the same volume" as a scaling strategy for a stateful service without checking whether that service's own storage engine tolerates it. Most don't, and I don't have a clean list of which ones do, so check the specific engine's docs before assuming either way.
An overlay network lets containers on different physical or virtual hosts talk to each other as if they were on the same local network, encapsulating traffic and routing it across the hosts' actual network underneath. A bridge network is confined to a single Docker host by design, it has no concept of "another machine" at all.
Overlay networks are a Swarm mode construct specifically. Kubernetes solves the same cross-host problem with its own CNI plugins, Calico and Cilium among others, not Docker overlay networks, worth knowing so you don't describe Kubernetes networking using Swarm vocabulary by mistake.
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:2.4.1 --push .A single tag like myapp:2.4.1 can actually point at a manifest list, not a single image, one that references a separate image built for each CPU architecture. When someone runs docker pull myapp:2.4.1, the daemon reads that manifest list and silently pulls whichever architecture-specific image matches the host it's running on, an Intel CI runner gets the amd64 build, an M-series Mac gets the arm64 one, same tag, different bytes.
buildx, Docker's extended build tool built on BuildKit, builds each architecture's image (often using QEMU emulation for the architecture that doesn't match the build machine) and pushes the whole manifest list under one tag with --push. Before buildx became standard, teams handled this by publishing separate myapp:2.4.1-amd64 and myapp:2.4.1-arm64 tags and making every consumer pick the right one manually, which is exactly the manual step multi-arch manifests remove.
# Hard cap: this container can never use more than 1.5 CPU cores total
docker run --cpus=1.5 myapp
# Relative weight: only matters when CPUs are actually contended
docker run --cpu-shares=512 myapp # default is 1024--cpus sets a hard ceiling, a container capped at 1.5 will never use more than one and a half CPU cores' worth of time, full stop, even if the host has sixteen idle cores sitting right there doing nothing. --cpu-shares sets a relative weight instead of a hard cap, and it only actually does anything once multiple containers are competing for the same CPU at the same time. If the host isn't under CPU pressure, a container with a low cpu-shares value can still use as much CPU as it wants.
That difference trips people up constantly. A container set to --cpu-shares=512 (half the default weight) looks completely uncapped and fast on an idle host, then suddenly gets starved down to a fraction of its usual throughput the moment a handful of other containers start contending for the same cores. --cpus is the right tool when you need a predictable, testable ceiling. --cpu-shares is the right tool when you want fair-ish scheduling under contention but don't want to waste idle capacity the rest of the time.
$ docker exec -it my-container bash
OCI runtime exec failed: exec failed: unable to start container process:
exec: "bash": executable file not found in $PATH: unknownThe image genuinely doesn't have bash installed, common on Alpine images (which ship sh, not bash) and near-universal on distroless or scratch-based images, which often have no shell at all by design, part of how they stay small and reduce attack surface.
Try sh instead of bash first, it covers the Alpine case. For genuinely shell-less images, there's no shell to exec into at all, and the real fix is a purpose-built debug tool: docker debug attaches a full toolbox to a running container without touching the image itself. Interviewers ask this because "just add bash to the Dockerfile" is the wrong instinct for a production image made minimal on purpose.
docker inspect my-container --format '{{.State.OOMKilled}}' returns true if the kernel's OOM killer, not your application, terminated the process. The exit code gives it away too, 137, 128 plus signal 9 (SIGKILL), the one signal you can't catch or handle gracefully in application code.
From there: dmesg or journalctl -k on the host shows the kernel's own OOM killer entries naming the exact process and its memory footprint at the moment it died. Check whether a --memory limit is actually set (no limit means the container can consume all available host memory before anything intervenes) and whether that limit's realistic for the workload. Too low kills a healthy app under normal load, too high just delays the same crash until it takes the whole host down instead of one container.
How to prepare for a Docker interview in 2026
Skip another flashcard pass on Dockerfile syntax. Build one small multi-service app instead, an API plus a database plus Compose wiring them together, then break things on purpose: strip the healthcheck and watch the API crash on first boot, swap COPY order and time the rebuild before and after, run the API as root and then lock it down with USER and see what actually breaks. Fixing your own mess teaches the model faster than reading about someone else's.
Across mock interview sessions run through LastRoundAI tagged DevOps or backend, the most common stumble isn't a trivia-style definition question. It's candidates who define CMD and ENTRYPOINT correctly and then can't explain what happens when a Dockerfile sets both, the exact follow-up from earlier on this page. I don't have a clean percentage on it, only that reviewers keep flagging it often enough to call out here.
Interviewers in 2026 also expect at least a passing sense of where Docker's boundaries sit against Kubernetes, since most teams run both. Knowing that Docker builds and runs one host's containers while Kubernetes schedules and networks many hosts' worth of them is usually enough for a Docker-focused loop. Anything deeper belongs in Kubernetes prep, not here.
Get the reps in before the real thing
Reading an answer to a build-cache question is not the same as defending it live once an interviewer changes one variable on you, swapping which file gets copied first, say, and asking you to predict what rebuilds. LastRoundAI's mock interview mode runs DevOps and backend-focused rounds with real-time follow-up questions instead of a static bank, and the free plan includes 15 credits a month that reset monthly. Starter is $19/mo if you want more sessions than that covers.
If the harder part right now is finding enough DevOps, platform, or backend roles rather than passing the interview once you land one, Auto-Apply matches and applies to roles for you, 10 a month on the free plan, up to 400 a month on the Ultimate plan, with every application held in a review queue until you approve it. Nothing goes out on your behalf without you seeing it first.
Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

