Real Docker interview questions with detailed answers on images, Dockerfiles, networking, volumes and debugging, grouped by difficulty.
100questions with answers
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). 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, so inside the container they look like one single filesystem, while Docker physically stores only the differences each layer introduces.
You can see them yourself with docker history <image>, which lists every layer and its size, newest first. Layers are also the unit that 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 that 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. 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. 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 saying 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 once (its image or build context, ports, volumes, environment, and any networks) 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 find each other by service name with no manual network setup.
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. The main reasons are to keep proprietary images off a public service and to control who can pull them.
docker stop sends the main 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 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.envDocker is a tool for packaging an application together with everything it needs to run (code, runtime, libraries and configuration) into a container image, so it behaves the same on any machine that has Docker installed. It solves the classic "it works on my machine" problem.
Before containers, a team often had to install the same versions of a language, libraries and system packages by hand on every laptop, test server and production host, and small differences caused bugs that were hard to reproduce. With Docker you describe the environment once in a Dockerfile, build an image, and run that same image everywhere. Containers are also lightweight, because they share the host's kernel instead of booting a whole operating system, so they start in about a second and many can run on one machine.
Typical uses are consistent local development environments, repeatable CI builds, and deploying applications (often as many small services) to servers or a cluster.
A container is always in one of a few states: created (it exists but has never started), running, paused (its processes are frozen but kept in memory), restarting (Docker is bringing it back after a restart policy triggered), exited (the main process ended), and dead (a rare, broken state where removal failed).
docker create --name demo nginx # createddocker start demo # runningdocker pause demo # pauseddocker unpause demo # running againdocker stop demo # exiteddocker rm demo # goneThe key idea is that an exited container still exists. It keeps its writable layer, its name and its logs until you remove it with docker rm. That's why docker ps -a shows containers that docker ps doesn't, and why old stopped containers pile up if you never clean them.
A handful of flags cover most daily use:
-d runs the container in the background (detached) instead of tying up your terminal.-it gives you an interactive terminal. -i keeps stdin open and -t allocates a pseudo-terminal, which you need for a shell.--rm deletes the container automatically when it exits, so it doesn't pile up.--name gives the container a readable name instead of a random one.-p host:container publishes a port.-v mounts a volume or bind mount.-e KEY=value sets an environment variable.docker run -d --name web --rm -p 8080:80 -e NGINX_HOST=example.com nginxdocker run -it --rm ubuntu bashA good habit to mention: use --rm for one-off commands so you don't build up a graveyard of stopped containers.
The build context is the set of files Docker sends to the daemon when you run docker build. In docker build -t myapp ., the final . is the context: that directory and everything under it.
It matters because a COPY or ADD instruction can only use files that are inside the context. Something like COPY ../secrets.txt . fails, because that file is outside what Docker received. It also matters for speed. Docker uploads the whole context before the build starts, so a context that contains node_modules, a .git folder or large data files makes every build slower. A .dockerignore file trims what gets sent.
docker build -t myapp . # context is the current directorydocker build -t myapp ./backend # context is the backend folder onlyWORKDIR sets the working directory for every instruction that follows it (RUN, CMD, ENTRYPOINT, COPY and ADD), and for the running container's main process. If the directory doesn't exist, Docker creates it.
WORKDIR /appCOPY . .CMD ["python", "app.py"]Use WORKDIR instead of RUN cd /app. A cd inside a RUN only applies to that single instruction, because each RUN starts fresh in the previous working directory. WORKDIR persists, and it makes relative paths in the rest of the Dockerfile predictable. It can be used several times, and relative paths build on the previous one.
docker run creates a new container from an image and starts it. docker start restarts an existing stopped container, keeping its writable layer, name and configuration.
So running docker run nginx twice gives you two separate containers, each with its own writable layer. Running docker start web on a stopped container named web brings that same container back, with any files it wrote still there (as long as it was never removed).
docker run -d --name web nginx # creates and starts a new containerdocker stop webdocker start web # starts the same container againA common trap is repeatedly calling docker run for the same thing and ending up with many stopped containers. Use --rm for throwaway runs, or reuse one named container with start.
Use docker logs <container>. It prints whatever the container's main process wrote to stdout and stderr.
docker logs web # everything so fardocker logs -f web # follow new output, like tail -fdocker logs --tail 100 web # only the last 100 linesdocker logs --since 10m web # only the last 10 minutesdocker logs -t web # add timestampsThe important detail is that Docker only captures stdout and stderr. If your application writes to a log file inside the container, such as /var/log/app.log, docker logs shows nothing. The container-friendly pattern is to log to stdout and let Docker (or your log driver) collect it.
A dangling image is an image layer that has no tag, shown as <none>:<none> in docker images. It usually appears when you rebuild an image with the same tag. The tag moves to the new build, and the previous image is left behind with no name.
docker images -f dangling=true # list themdocker image prune # remove dangling imagesDon't confuse "dangling" with "unused". A dangling image has no tag. An unused image has a tag but no container is using it. docker image prune removes only dangling ones, while docker image prune -a removes all unused images, which is more aggressive.
docker inspect prints low-level details about a Docker object (a container, image, volume or network) as JSON. For a container that includes its state, exit code, IP address, mounts, environment variables, restart policy and more.
You rarely want the full JSON, so use --format (or -f) with a Go template to pull out one value:
docker inspect -f '{{.State.ExitCode}}' webdocker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' webdocker inspect -f '{{json .Mounts}}' webIt's one of the first tools to reach for when debugging: it tells you what Docker actually configured, which can differ from what you thought you asked for.
docker compose up creates and starts the services (and networks and volumes) defined in your Compose file. docker compose stop stops the containers but leaves them in place, so docker compose start can bring them back. docker compose down stops the containers and removes them, along with the project's networks.
docker compose up -d # create and start in the backgrounddocker compose stop # stop, keep containersdocker compose down # stop and remove containers and networksdocker compose down -v # also remove named volumesThe trap is -v. Named volumes survive a plain down, which is what keeps your database data safe between restarts. Adding -v deletes them, and with them the data.
A base image is the image named in your Dockerfile's FROM line. Everything you add is layered on top of it. Common choices are ubuntu, alpine, debian-slim, or a language image like python:3.12-slim.
scratch is a special, completely empty image. It contains no files, no shell and no libraries. It's useful for a program that is a single statically linked binary (common with Go and Rust), because that binary needs nothing else to run.
FROM scratchCOPY server /serverENTRYPOINT ["/server"]The result is tiny and has almost no attack surface. The catch is that there is no shell and no tools, so you can't docker exec into it to debug, and the binary must be fully static.
docker pull downloads a ready-made image from a registry such as Docker Hub. docker build creates a new image locally, by following the instructions in a Dockerfile.
docker pull nginx:1.27 # download an existing imagedocker build -t myapp:1.0 . # build your own from a DockerfileUse pull when someone else already publishes what you need (a database, a web server). Use build when the image contains your own code. Also note that docker run pulls automatically if the image isn't found locally, so you rarely need to call pull by hand, except to refresh a tag like latest or to download ahead of time.
Docker has a client-server design. The daemon (dockerd) is the background service that does the real work: building images, running containers, managing networks and volumes. The client is the docker command you type. It doesn't do the work itself. It sends requests to the daemon over a REST API, usually through a Unix socket at /var/run/docker.sock.
You can see both sides with docker version, which prints a Client section and a Server section.
This explains a very common error, "Cannot connect to the Docker daemon". The docker command is fine, but the daemon isn't running, or your user doesn't have permission to use its socket. It also explains how the same CLI can control a remote machine: point it at a different daemon.
VOLUME /data declares that /data holds data that should live outside the container's writable layer. When a container starts, Docker mounts a volume there. If you didn't supply one with -v, Docker creates an anonymous volume with a random name.
FROM postgres:16VOLUME /var/lib/postgresql/dataTwo things are worth knowing. First, anonymous volumes are hard to find and easy to leave behind, so for real data most people create a named volume at run time (-v pgdata:/var/lib/postgresql/data) instead of relying on the default. Second, changes made to that path by Dockerfile instructions that come after the VOLUME line can be discarded, which surprises people who try to prepare files in a directory they just declared as a volume.
A 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, built from that image. Third, it starts the container by launching the image's ENTRYPOINT and 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. That is more aggressive, and it can delete images you meant to reuse, so check what it will remove before you confirm.
The practical reason this question comes up: stopped containers and orphaned layers quietly eat disk space on build servers and dev machines. 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 and volumes usedocker 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'# the 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 find each other by name. This 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, so 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: the 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: the 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 doesn't mean "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, since 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.
rm -rf in a later layer doesn't shrink the layer that already stored the files..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 from those two changes alone.
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.txtCOPY . .CMD ["python", "app.py"]Here the pip install layer is reused until requirements.txt changes, and COPY . . sits last because it changes on every commit. 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 is the kind of thing that differs between dev and prod but isn't sensitive. It 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, ENV or ARG. It ends up permanently readable in the image's layers, and anyone who pulls the image can extract it with docker history or by inspecting the layer filesystem, 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 or 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 like 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 can sit 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. This is the root cause of the "why does stop take ten seconds" scenario (see that question). 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 <- the command is 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, with 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 visibility, because orchestrators and Compose can act on the health status. In Docker Compose, depends_on with condition: service_healthy waits for a dependency to report healthy, not merely started, before bringing up a service that needs it. That is exactly the gap the depends_on question below 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 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. That is 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, such as debugging utilities, a monitoring stack, or load-testing containers, that most people 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. This 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, with no rebuild. Named volumes are the better choice for anything you want to persist reliably in production, such as a database's data directory. 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, with Docker handling 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, 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, which replaced the older, simpler builder. It runs build steps in parallel when they don't depend on each other, skips stages of a multi-stage build whose output nothing else needs, supports build-time caches and secrets, 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. This solves the exact "secrets end up in docker history" problem from the configuration question, 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. The most common use is docker exec -it mycontainer sh, which gives you 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 and stderr streams.
The practical difference matters when things go wrong. Exiting an exec session just ends that one extra process and leaves the container's main process untouched. Exiting an attached session carelessly (without using the 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, because Docker uses that prefix to know where to send it. Then push it.
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. A missing or wrong tag is the most common reason a push goes to the wrong destination, or fails with an auth error against a registry you never meant to reach.
A logging driver controls where a container's stdout and 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. It's 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, such as syslog, journald, fluentd, awslogs, or gelf, so logs from every container land in one searchable place rather than scattered across each host's local disk. The trade-off to mention: with some drivers, docker logs on that container may stop working locally, because the output is being sent elsewhere instead of kept 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). --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, see the exit code 137 question) 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, such as a production deployment manifest or 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 documentation use, 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), newer features such as profiles and depends_on health conditions, and the command itself. Scripts and CI pipelines still calling the hyphenated docker-compose binary need updating, since it isn't guaranteed to be installed anymore.
docker compose version # v2, built into the Docker CLIdocker cp copies files directly between a container's filesystem and the host, as a one-off, without needing any mount configured ahead of time. It's 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. It's set up at container-creation time and stays 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 writes into that same writable layer unless the destination path happens to be a mount.
Docker is a stack of components, each with one job.
Why this matters: containerd and runc follow open standards (the OCI specs), so the same image can run on other runtimes. It's also why Kubernetes can run Docker-built images without Docker itself. It talks to containerd directly.
Inside a container, localhost (127.0.0.1) means that container itself, not your computer and not any other container. Each container has its own network namespace and its own loopback interface.
This causes a classic bug. Your app in a container tries to connect to a database at localhost:5432, but the database is running on your host machine or in another container, so the connection is refused.
The fix depends on where the other service is:
db:5432.host.docker.internal. It works out of the box on Docker Desktop, and on Linux you add --add-host=host.docker.internal:host-gateway.The reverse problem exists too. An app inside a container that listens only on 127.0.0.1 can't be reached through a published port. It must listen on 0.0.0.0.
ports publishes a container port on the host, so it can be reached from your machine or from outside. expose only documents or opens a port for other containers on the same Docker network. It does not publish anything to the host.
services: api: build: . ports: - "8080:8080" # reachable from the host db: image: postgres:16 expose: - "5432" # documentation only; other services can reach it anywayOn a user-defined network (the Compose default), containers can already reach each other on any port the process listens on, so expose is mostly documentation. The practical rule: publish only what the outside world needs. A database that only the API talks to should have no ports entry. Publishing it needlessly makes it reachable from the host network, which is both a security risk and a common source of port conflicts.
Compose can merge several files into one configuration. By default it reads compose.yaml and, if present, compose.override.yaml, and merges the override on top automatically. You can also pick files explicitly with -f, and later files win.
docker compose up # compose.yaml + compose.override.yamldocker compose -f compose.yaml -f compose.prod.yaml up -ddocker compose -f compose.yaml -f compose.prod.yaml config # show the merged resultA typical split puts the shared definition in compose.yaml, development extras in the override (bind mounts, debug ports), and production settings in compose.prod.yaml (restart policies, resource limits, no source mounts).
Merge rules in short: single values are replaced by the later file, mappings (like environment) are merged, and lists (like ports) are appended. When the result surprises you, run docker compose config to see exactly what Compose will use.
There are two separate mechanisms, and mixing them up is a very common mistake.
.env file in the project directory is used for substitution inside the Compose file. If your YAML says image: postgres:${PG_VERSION}, Compose fills in PG_VERSION from .env or from your shell.env_file: and environment: set variables inside the container.services: api: image: myapi:${API_TAG} # substituted from .env or the shell env_file: - ./api.env # becomes container environment environment: LOG_LEVEL: debug # also becomes container environmentSo .env does not automatically put variables into your container. Only environment and env_file do. If the same variable is set in both of those, the value in environment wins, and both override a value set with ENV in the image. A shell variable overrides the same name in .env when Compose substitutes values.
The -p flag accepts several forms:
| Form | Meaning |
|---|---|
-p 8080:80 |
Host port 8080 to container port 80, on all host interfaces |
-p 127.0.0.1:8080:80 |
Same, but only reachable from the host itself |
-p 80 |
Container port 80 to a random free host port |
-p 8080:80/udp |
Map a UDP port instead of TCP |
-P (capital) |
Publish every port the image EXPOSEs, to random host ports |
The second form is the one to remember for safety. A plain -p 5432:5432 makes the database reachable from other machines on the network. Binding to 127.0.0.1 keeps it local.
A well-known gotcha: Docker adds its own firewall rules (iptables) to make published ports work, so a port you publish can bypass rules set with host tools such as ufw. Do not assume the host firewall protects a published port. Bind to 127.0.0.1 or don't publish it.
With --network host, the container does not get its own network namespace. It shares the host's network stack directly, so a process listening on port 80 inside the container is listening on port 80 of the host. Port mapping with -p is ignored.
docker run -d --network host nginxThe upside is speed and simplicity. There is no NAT or bridge in the path, which helps network-heavy workloads and tools that need to see the host's real interfaces. The downsides are real, though:
Treat it as a deliberate exception, not a default.
When both are set (in exec form), the final command is ENTRYPOINT followed by CMD. ENTRYPOINT is the fixed program, and CMD provides default arguments that can be replaced.
| Dockerfile has | What runs |
|---|---|
Only CMD ["nginx", "-g", "daemon off;"] |
The CMD |
Only ENTRYPOINT ["python", "app.py"] |
The ENTRYPOINT |
ENTRYPOINT ["python"] and CMD ["app.py"] |
python app.py |
How overrides work:
docker run myimage other.py # replaces CMD, so: python other.pydocker run --entrypoint sh -it myimage # replaces ENTRYPOINTTwo traps. A shell-form ENTRYPOINT (written without brackets) ignores CMD and any arguments you pass to docker run. And since --entrypoint is needed to replace the entrypoint, a container whose entrypoint is a wrapper script is harder to debug. That's the reason docker run --entrypoint sh is such a useful trick.
Because every RUN creates a layer, and layers are permanent. If you install packages in one RUN and delete the cache in a later RUN, the cache still exists in the earlier layer, so the image doesn't get smaller. Cleaning up in the same RUN means the files never end up in the layer at all.
RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*There is a second reason to keep apt-get update and apt-get install together. If update is in its own cached layer, a later change to the install line reuses a stale package list and can fail or install old versions.
The trade-off is cache granularity. One giant chained RUN rebuilds completely on any change. Group commands that belong together and change together, not everything.
docker commit takes the current state of a container, including the files changed in its writable layer, and saves it as a new image.
docker run -it --name tmp ubuntu bash# install things by hand inside the container, then exitdocker commit tmp my-ubuntu-with-toolsIt works, but it's a poor way to build images. The result is not reproducible, because nobody can tell what was done by hand. It isn't reviewable, because there's no file to read or put in version control. It can also capture junk such as shell history, temp files or accidentally typed secrets.
A Dockerfile fixes all of that: it's a readable, repeatable recipe. docker commit is still handy for quick experiments, or for saving a container's state for debugging. For anything you ship, write a Dockerfile.
Both move things between machines as tar files, but they work on different objects.
docker save and docker load work on images. They keep all layers, tags and metadata (such as CMD and ENV).docker export and docker import work on a container's filesystem. The result is a single flattened filesystem with no layers and no image metadata, so CMD, ENV and ports are lost.docker save -o app.tar myapp:1.0 # image to tardocker load -i app.tar # tar back to image docker export web > web-fs.tar # container filesystem to tardocker import web-fs.tar web-flat # becomes a new, flattened imageUse save and load to move an image to a machine with no registry access, such as an air-gapped network. Use export and import only when you really want a flat snapshot of a container's files.
Use an image scanner. It unpacks the image layers, lists the operating system packages and language dependencies it finds, and compares them with databases of known vulnerabilities (CVEs). Common tools are Docker Scout, Trivy and Grype.
docker scout cves myapp:1.0trivy image myapp:1.0trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:1.0 # fail a CI jobGood practice is to scan in CI on every build, fail the build above a severity you choose, and rescan images that are already deployed, since new CVEs are published daily against old images.
Be ready to explain the limits. A CVE in a package doesn't always mean your app is exploitable, because the vulnerable code may never run. The most effective fix is usually simply updating the base image, or switching to a smaller one with fewer packages to flag. A scan result is information, not a verdict.
A container is an ordinary process with two kernel features applied. Namespaces limit what the process can see. Cgroups limit what it can use.
| Namespace | What the container gets its own copy of |
|---|---|
pid |
Process IDs, so the app sees itself as PID 1 |
net |
Network interfaces, IP address, routing and ports |
mnt |
Mount points and filesystem view |
uts |
Hostname |
ipc |
Shared memory and message queues |
user |
User and group ID mapping (when enabled) |
You can see the effect directly:
docker run --rm busybox ps # only its own processes are visibledocker run --rm --hostname demo busybox hostname # prints: demoCgroups (control groups) then cap CPU, memory, I/O and the number of processes, which is what --memory and --cpus set. Together, namespaces and cgroups are the whole trick: Docker mostly makes them easy to use.
A volume is just storage managed by Docker, so the usual approach is a short-lived helper container that mounts the volume and a host folder, then runs tar.
# back up volume "mydata" into ./mydata.tgzdocker run --rm -v mydata:/data -v "$(pwd)":/backup busybox \ tar czf /backup/mydata.tgz -C /data . # restore it into a (new) volumedocker run --rm -v mydata:/data -v "$(pwd)":/backup busybox \ tar xzf /backup/mydata.tgz -C /dataOne caution matters in an interview. For a database, copying its files while it is running can capture an inconsistent state. Prefer the database's own tool (pg_dump, mysqldump) for a logical backup, or stop the container briefly before copying the files. A file-level volume backup is best for plain file data.
Image layers are read-only and shared. When a container starts, Docker adds a thin writable layer on top. If the container modifies a file that lives in a lower, read-only layer, Docker first copies that file up into the writable layer and changes the copy. The original is untouched. This is called copy-on-write.
The benefits are that many containers can share the same image layers on disk, and starting a container is nearly instant because nothing is copied up front.
The mechanism is provided by a storage driver, and overlay2 is the default on modern Linux. It uses the kernel's OverlayFS to stack layers into one merged view.
The practical consequence: writing a lot of data into the container's writable layer is slower than writing to a volume, and the data disappears when the container is removed. Write-heavy paths, like database files, belong on a volume.
Both attach storage to a container. -v (--volume) uses a compact colon-separated string. --mount uses explicit key=value pairs and is more readable.
docker run -v mydata:/data myimagedocker run --mount type=volume,src=mydata,dst=/data myimage docker run -v "$(pwd)":/app myimagedocker run --mount type=bind,src="$(pwd)",dst=/app myimageThe main behavioral difference is for bind mounts. If the host path doesn't exist, -v silently creates it as a directory (often owned by root), which hides typos. --mount fails with an error instead, which is safer.
--mount also makes the type (volume, bind, tmpfs) obvious and is easier to read in long commands. Both are fine, and Docker documents --mount as the more explicit choice.
Docker has a few built-in commands for a quick look:
docker stats # live CPU, memory, network and disk I/O for all containersdocker stats --no-stream # one snapshot instead of a live viewdocker top web # processes running inside the containerdocker events # live stream of Docker events (start, die, oom...)docker stats is the fast way to answer "is this container using too much memory?" or "is it CPU-bound?". Its memory figure is measured against the container's limit, so a container with no limit is measured against the host.
For anything beyond a quick check, teams collect metrics continuously with a monitoring stack. Common choices are cAdvisor for container metrics, Prometheus to store them and Grafana to chart them. The built-in commands show you the present moment only, and a problem that happened last night needs history.
The common guidance is one main concern per container, which is a convention and not a hard rule. A container runs as long as its PID 1 process runs, and the clean design is one container for the web app, another for the database, another for the worker.
The reasons are practical. Each part can be scaled, updated and restarted on its own. Logs stay separate and simple. And a crash in one component doesn't hide inside another.
Real exceptions exist. An application may legitimately spawn helper processes, and sometimes tools must run side by side (a sidecar pattern in orchestrators puts them in separate containers that share a network). If you truly need several processes in one container, don't start them with a shell script that backgrounds things. Use a small init or process supervisor (tini, s6, supervisord) so signals are forwarded and zombie processes are reaped. Even then, ask whether two containers would be cleaner.
There are two common approaches. The older one is a bind mount of your source directory into the container, with a dev server inside that watches for changes (nodemon, Vite, Flask debug mode). You edit on your host and the container sees it immediately.
services: web: build: . volumes: - ./src:/app/srcThe newer one is Compose Watch, which watches your files and either syncs them into the container or rebuilds the image, depending on the rule.
services: web: build: . develop: watch: - action: sync path: ./src target: /app/src - action: rebuild path: package.jsondocker compose watchUse sync for source files and rebuild for things like dependency manifests, where a full rebuild is the right response. Compose Watch avoids some bind mount problems, such as slow file I/O on macOS and Windows and host files shadowing the dependencies installed in the image.
Use Docker Buildx, which is BuildKit's multi-platform front end. You tell it which platforms to build for, and it produces one image reference that holds a variant for each architecture (a manifest list). When someone pulls it, Docker picks the variant that matches their CPU.
docker buildx build \ --platform linux/amd64,linux/arm64 \ -t myrepo/myapp:1.0 \ --push .You usually need --push (or --output), because a multi-platform result can't be loaded into the local image store in the classic way. You may also need to create a builder first with docker buildx create --use.
Why it matters: developers on Apple Silicon build arm64 images, while many servers run amd64. An image built for the wrong architecture fails with exec format error. Building both removes that whole class of problem.
Compose has a secrets feature that mounts a secret into a container as a file under /run/secrets/<name>, instead of putting it in an environment variable. The source can be a file on the host (or an environment variable on the machine running Compose).
services: db: image: postgres:16 environment: POSTGRES_PASSWORD_FILE: /run/secrets/db_password secrets: - db_password secrets: db_password: file: ./db_password.txtFiles are safer than environment variables for a few reasons. Environment variables show up in docker inspect, in crash dumps and in child processes, while a mounted file is only readable by processes that open it. Many official images (Postgres, MySQL) support a _FILE variant so they read the secret from a path.
Be honest about the limit: this protects against casual exposure but is not a full secrets manager. For production you would typically use your orchestrator's secret store or a tool such as Vault.
Running with --read-only makes the container's root filesystem read-only. The process can't create or change files in the image's filesystem. Anything that genuinely needs to write gets a small writable spot, such as a tmpfs (in-memory) mount or a volume.
docker run --read-only --tmpfs /tmp myimageThe benefit is damage limitation. If an attacker gets code running in the container, they can't drop a script into /usr/bin, modify your application files or install tools. It also catches sloppy applications that write to unexpected places.
The cost is that some programs expect to write somewhere (logs, caches, PID files, temp files) and will crash with "read-only file system" errors until you give them a writable path. A good approach is to turn it on, watch what fails, and mount tmpfs or volumes for exactly those paths.
Traditionally "root" could do everything. Linux splits that power into around forty separate capabilities, such as binding low ports (NET_BIND_SERVICE), changing file ownership (CHOWN), or loading kernel modules (SYS_MODULE). Docker starts containers with a reduced default set, and you can tighten or loosen it.
The least-privilege approach is to drop everything and add back only what you need:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE \ --security-opt no-new-privileges \ myimage--cap-drop=ALL removes all capabilities.--cap-add gives back just one.no-new-privileges stops a process from gaining extra privileges later (for example through a setuid binary).Docker also applies a default seccomp profile that blocks a set of dangerous system calls. Never run --privileged just to make an error go away. It grants nearly all capabilities and device access, which removes most of the isolation. Find the one capability that is actually missing.
Normally the Docker daemon runs as root, so anyone who can control it effectively has root on the host. Rootless mode runs both the daemon and the containers as an ordinary, unprivileged user, using user namespaces. Even if an attacker escapes a container, they land as that normal user, not as root.
Trade-offs of rootless mode: binding ports below 1024 needs extra configuration, some storage and networking features are limited, and performance of some network paths is lower.
Podman is a different container engine with a Docker-compatible command line (podman run, podman build). It is daemonless, because each container is started directly as a child process of the user, and it runs rootless by default. Many people alias docker to podman and keep their existing workflows.
The takeaway for an interview: both approaches reduce the damage of a container escape by removing root from the picture. Docker's rootless mode keeps the Docker tooling, and Podman changes the architecture.
The message means something on the host is already using the host port you tried to publish. It is almost always one of three things: another container publishing the same port, an old container that's still running (or a leftover from a previous Compose run), or a normal program on the host, such as a local Postgres or a dev server.
Find the owner first:
docker ps --filter publish=8080 # any container using host port 8080?ss -tlnp | grep 8080 # Linux: which process listens on itlsof -i :8080 # macOS and Linux alternativeThen choose a fix:
-p 8081:8080. The container's own port doesn't have to change.docker compose down --remove-orphans.The mistake to avoid is randomly restarting Docker. Identify what actually holds the port, because otherwise it will come back.
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, and 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, and 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 (see the non-root 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 unless you turn on user namespace remapping or rootless mode, that root is the same UID 0 the host kernel sees. 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), 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 appuserYou can also override the user at run time:
docker 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 that 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 has hardened a container in production from one who has only read about it.
Because the process inside the container never receives, or never handles, the SIGTERM that docker stop sends first. 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 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. That is 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. 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 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, with no sh to get a foothold and 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. Others use docker debug or ephemeral debug containers that attach a toolbox without modifying the production image itself. It's the same underlying instinct as running non-root: minimize what's present, and 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, and it almost always points 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.
apt-get clean in a later instruction doesn't shrink the layer where the packages were originally installed, since that layer is already frozen..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, with 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, 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 means nothing crashed, so the container stopped exactly as designed. A container only lives as long as its PID 1 process, and 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, check 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. Check three things, 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, or to a step before your COPY whose command text didn't change even though its real behavior did.
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 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 and docker rm followed by a fresh docker run) clears this up.
Most of the time 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.
It was sent SIGKILL. 137 is 128 + 9, where 9 is the signal number for SIGKILL. Linux 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, 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. Watching docker stats while the container runs (or your orchestrator's metrics) will show whether memory usage is flat (the 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, and a fresh container from the same image starts with a clean writable layer every time.
The fix is to route anything you 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 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, such as a database's data directory, uploaded files, or 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.
Both orchestrate containers across multiple hosts, handling scheduling, scaling, service discovery, rolling updates, and self-healing. But they sit at very different points on the complexity-versus-capability curve, and the honest answer 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 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 network 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 explain why a small, simple deployment might 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. 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. A good method to state out loud is "logs first, network second, shell third", because it shows a method rather than a lucky guess.
Logs first. Run 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 is listening, and on which interfaceThen get a shell. Run 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 (a deadlock, an exhausted connection pool, or a dependency it's waiting on).
Resource limits last. Run 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.
Mounting the Docker socket (-v /var/run/docker.sock:/var/run/docker.sock) gives the container full control of the host's Docker daemon. Whoever controls the daemon can start any container, and that is effectively root on the host.
For example, a process with the socket can run:
docker run --rm -it -v /:/host --privileged alpine chroot /hostThat mounts the host's entire filesystem and gives a root shell on it. So a compromise of any container that has the socket (a CI job, a dashboard, a bug in a web app) becomes a compromise of the whole machine.
People mount it because tools need to build or manage containers: CI runners, Traefik, Portainer, monitoring agents. Safer options depend on the need:
:ro on the socket file alone does not restrict API calls, so a proxy is the stronger control.The key sentence for an interview: "access to the Docker socket equals root on the host".
Start by measuring, then clean up carefully. Docker usually fills a disk through five things: old images, stopped containers, unused volumes, the build cache, and container logs that grow without limit.
docker system df # space used by images, containers, volumes, build cachedocker system df -v # per-item detailsudo du -sh /var/lib/docker/* # what is big on disk (default Linux location)Then clean in order of safety:
docker container prune # stopped containersdocker image prune -a # unused imagesdocker builder prune # build cacheBe careful with volumes. docker volume prune deletes volumes not attached to a container, which can include a stopped database's data. Look before you delete.
For a log problem, check the biggest *-json.log files under /var/lib/docker/containers/. The permanent fix is log rotation, set in /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }}Restart the daemon after editing. The setting applies to containers created afterwards, so existing ones need to be recreated. A scheduled prune plus log rotation stops this from coming back.
Split the problem into connectivity and DNS, because they fail for different reasons.
docker run --rm busybox ping -c 2 1.1.1.1 # raw connectivity, no DNS neededdocker run --rm busybox nslookup example.com # DNSdocker run --rm --dns 1.1.1.1 busybox nslookup example.com # DNS with another serverHow to read the results:
--dns on the container, or "dns": ["1.1.1.1"] in /etc/docker/daemon.json.daemon.json fixes it.Remember that on a user-defined network, containers use Docker's embedded DNS at 127.0.0.11, which forwards outside names to the host's resolvers. So a broken host resolver breaks container DNS too. Test from the host first. If the host can't resolve names either, Docker isn't the problem.
CI is slow for a specific reason: each job often runs on a fresh machine with an empty build cache, so every layer rebuilds every time. The fix is to make the cache survive between runs, and to reduce what needs building.
docker buildx build \ --cache-from type=registry,ref=myrepo/myapp:buildcache \ --cache-to type=registry,ref=myrepo/myapp:buildcache,mode=max \ -t myrepo/myapp:1.0 --push .RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt.dockerignore, and use multi-stage builds so unused stages are skipped.Mention that the first run is still slow and the gains show on later runs. Also mention checking the cache hit rate in the build output, so you measure the improvement and don't guess.
It follows a short checklist: a minimal and pinned base image, a multi-stage build so tools don't ship, a non-root user, only production dependencies, no secrets, and the exec form for the start command.
# Build stageFROM node:22-slim AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build # Runtime stageFROM node:22-slimENV NODE_ENV=productionWORKDIR /appCOPY --from=build --chown=node:node /app/dist ./distCOPY --from=build --chown=node:node /app/package*.json ./RUN npm ci --omit=dev && npm cache clean --forceUSER nodeEXPOSE 3000CMD ["node", "dist/server.js"]Why each choice matters:
USER node: the process isn't root, so an escape is far less damaging.npm ci and a lockfile: reproducible installs.CMD: the app is PID 1 and receives stop signals.In a real project you would also pin the base image to an exact version or digest, add a .dockerignore, scan the image in CI, and at run time drop capabilities and use --read-only where the app allows it. Security is layered, and no single line makes it safe.
Files have a numeric owner (UID) and group (GID), and the kernel compares those numbers, not names. Inside the container your process runs as some UID, for example 1000 or a non-root user created in the Dockerfile. If the mounted directory on the host is owned by a different UID, such as root, that process has no permission to write.
Check both sides:
docker exec web id # which UID is the process?ls -ln /path/on/host # who owns the folder? (-n shows numbers)Typical fixes:
chown 1000:1000 /path/on/host.docker run --user "$(id -u):$(id -g)" ....:z or :Z to the mount (-v ./data:/data:z) so the container is allowed to access the files.Docker Desktop on macOS and Windows hides much of this with a translation layer, which is why a setup can work on a laptop and fail on a Linux server. Don't fix it with chmod 777, because that removes the protection instead of solving the mismatch.
Containers remove many differences but not all. Work through the usual suspects.
arm64, and the server may be amd64. The symptom is exec format error. Check with:docker image inspect --format '{{.Architecture}}' myapp:1.0The fix is a multi-platform build (docker buildx build --platform linux/amd64,linux/arm64).
latest may point to something different on the server. Compare digests, and deploy by digest..env file, shell exports) but were never passed on the server.Config.json and config.json are different files./app, and on the server the image's own /app is used.The method is to compare, not guess: image digest, architecture, environment variables and mounts, one by one.
Several components cooperate, and each does one step.
In words:
dockerd through the API.dockerd checks for the image locally, pulls any missing layers, and prepares the container's writable layer and an overlay filesystem. It also sets up networking, usually a virtual ethernet pair connected to the docker0 bridge, and an IP address.ENTRYPOINT and CMD.The insight to finish with: there is no virtual machine in this picture. Your app is a normal Linux process on the host, started with a restricted view of the system.
The goal is to know what is in an image, who built it, and that it hasn't been altered. Several practices stack together.
image@sha256:...) in anything important, so a moved tag can't swap the contents.docker buildx build --sbom=true --provenance=true -t myrepo/myapp:1.0 --push .Be clear on what each does: scanning finds known flaws, signing proves origin and integrity, and an SBOM tells you what to look for when a new vulnerability is announced.
It depends on the environment, and a balanced answer is better than "always" or "never".
For development and testing it is an easy yes. You get a throwaway Postgres or MySQL in seconds, with a version that matches production, and no installs on your machine.
For production it can work, but it isn't free. If you do it, the database needs real care:
postgres:16, not latest), so a restart doesn't silently upgrade it.services: db: image: postgres:16 volumes: - pgdata:/var/lib/postgresql/data stop_grace_period: 60s healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10svolumes: pgdata:Many teams still choose a managed database service in production, because backups, failover, patching and monitoring come built in, and those are the hard parts. Containers don't make a database stateless. They only make it easy to start.
Use the same images and different configuration. The goal is that what you test locally is as close as possible to what you ship.
Shared base (compose.yaml): define three services, api, db (Postgres) and cache (Redis), on one user-defined network. The services reach each other by name (db, cache).
Development (compose.override.yaml, picked up automatically):
watch the source for fast reloads.127.0.0.1 for local tools..env file.Production (compose.prod.yaml):
ports.restart: unless-stopped, health checks, and depends_on with service_healthy.services: api: image: registry.example.com/team/api:1.4.2 restart: unless-stopped ports: - "8080:8080" depends_on: db: condition: service_healthy deploy: resources: limits: memory: 512M db: image: postgres:16 volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s cache: image: redis:7volumes: pgdata:docker compose -f compose.yaml -f compose.prod.yaml up -dFinish by naming the limit of this design. Compose on a single host is fine for small systems, but if you need several hosts, rolling updates and self-healing across machines, that's the point to move to Swarm or Kubernetes.