Docker Interview Questions and Answers

Last updated:

Check out 60 of the most common Docker interview questions, then take an AI-powered practice interview

ContainersDocker ComposeDockerfileRegistrySwarm
60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

What exactly is a container at the Linux kernel level, and how does that differ from a virtual machine?

BasicFundamentals

Answer

A container is not a lightweight VM; it is a normal Linux process (or process tree) that the kernel isolates using two primitives: namespaces and cgroups. Namespaces control what the process can see: the PID namespace gives it its own process tree where its main process is PID 1, the network namespace gives it its own interfaces and routing table, the mount namespace gives it a private filesystem view, and UTS, IPC, and user namespaces isolate hostname, shared memory, and UID mappings. Control groups (cgroups, v2 on any modern distro) control what the process can use: CPU shares, memory limits, IO weight, and PID counts.

Add a copy-on-write union filesystem (overlay2) for the root filesystem and seccomp plus capability restrictions for syscall filtering, and you have a container. A VM, by contrast, virtualises hardware and boots a full guest kernel through a hypervisor like KVM. That is why a container starts in milliseconds and shares the host kernel, while a VM takes seconds and carries a whole OS.

The practical consequences interviewers want you to state: containers on one host share a single kernel, so a kernel panic or kernel-level exploit affects all of them; you cannot run a Windows container on a Linux host natively; and uname -r inside any container on a host returns the host's kernel version. On macOS and Windows, Docker Desktop actually runs a Linux VM under the hood, and every container runs inside that VM, which is why file sharing and networking behave differently there than on native Linux.

Key Points

  • Container = normal process + namespaces (visibility) + cgroups (resource limits)
  • No guest kernel: all containers share the host kernel
  • overlay2 copy-on-write filesystem provides the root filesystem
  • VMs virtualise hardware and boot a full kernel via a hypervisor
  • Docker Desktop on Mac/Windows runs containers inside a hidden Linux VM
Q2

Walk through what happens when you run docker run -d -p 8080:80 nginx.

BasicFundamentals

Answer

The docker CLI sends an API request over the Unix socket /var/run/docker.sock to dockerd. The daemon checks whether the nginx:latest image exists locally; if not, it resolves the tag against Docker Hub, pulls the manifest, then downloads only the layer blobs it does not already have (each layer is content-addressed by sha256 digest). It then asks containerd to create a container: containerd spins up a shim process and invokes runc, which creates the namespaces and cgroups, mounts the overlay2 filesystem assembled from the image layers plus a fresh writable layer, and execs the image's ENTRYPOINT/CMD as PID 1 inside the container.

Because of -d the CLI detaches and prints the container ID instead of streaming logs. The -p 8080:80 flag tells Docker to publish the port: a veth pair connects the container's network namespace to the docker0 bridge, the container gets an IP like 172.17.0.2, and Docker programs a DNAT rule (iptables or nftables) plus binds docker-proxy on the host so traffic hitting host port 8080 on 0.0.0.0 is forwarded to 80 in the container. Good candidates add the gotchas: publishing on 0.0.0.0 exposes the port on all host interfaces and historically bypassed ufw rules, so bind explicitly with -p 127.0.0.1:8080:80 for local-only services; and -d does not mean supervised, so pair it with a restart policy like --restart unless-stopped if the container should survive daemon restarts.

# What the single command decomposes into
docker pull nginx:latest              # manifest + missing layers only
docker create -p 8080:80 nginx        # containerd + runc set up ns/cgroups
docker start <id>                     # exec ENTRYPOINT as PID 1

# Inspect the result
docker inspect --format '{{.NetworkSettings.IPAddress}}' <id>
docker port <id>                      # 80/tcp -> 0.0.0.0:8080

# Local-only publishing (avoid exposing on all interfaces)
docker run -d -p 127.0.0.1:8080:80 --restart unless-stopped nginx
💡 Pro Tip: Interviewers love this question because it tests the whole stack in one answer: CLI vs daemon, registries, layers, containerd/runc, and networking. Practise saying it in under two minutes.
Q3

What is the difference between an image and a container, and how do image layers actually work?

BasicImages & Layers

Answer

An image is an immutable, content-addressed stack of filesystem layers plus a JSON config (env vars, default command, exposed ports, working directory). A container is a running (or stopped) instance of that image: the same read-only layers with one thin writable layer added on top, plus the kernel-level isolation around a process. You can run fifty containers from one image and the read-only layers are stored exactly once on disk; only each container's writable layer differs.

Layers are created at build time: most Dockerfile instructions that change the filesystem (RUN, COPY, ADD) produce a new layer containing only the difference from the layer below. The overlay2 storage driver merges them at runtime using a union mount: when a container reads a file, the kernel returns it from the topmost layer that contains it; when a container writes to a file from a lower layer, copy-on-write copies it up to the writable layer first, and deleting a file creates a whiteout entry rather than actually removing anything from the image. Two gotchas interviewers check: first, deleting a file in a later Dockerfile layer does not shrink the image, because the file still exists in the earlier layer (this is why RUN apt-get install && rm -rf /var/lib/apt/lists/* must happen in one instruction). Second, anything written to the container's writable layer is lost when the container is removed, which is why databases need volumes. docker diff <container> shows exactly what a container has changed relative to its image.

Key Points

  • Image = immutable layers + config JSON; container = image + writable layer + process
  • Layers are content-addressed by sha256 and shared across containers
  • overlay2 does copy-on-write; deletions are whiteout files, not real removals
  • Deleting files in a later layer never shrinks the image
  • docker diff shows the writable-layer changes of a running container
Q4

Explain the core Dockerfile instructions FROM, RUN, COPY, CMD and how they map to layers.

BasicDockerfile

Answer

FROM sets the base image and must be the first meaningful instruction (only ARG may precede it). Every image chain terminates in a real base like debian:bookworm-slim, alpine:3.20, or the special FROM scratch, which is an empty filesystem used for static Go/Rust binaries. RUN executes a command at build time inside a temporary container and commits the filesystem result as a new layer; it is where you install packages and compile code.

COPY transfers files from the build context into the image and also creates a layer. CMD sets the default command executed when a container starts; it creates no filesystem layer, only metadata in the image config, and it is trivially overridden by whatever you pass after the image name in docker run. The layer mapping matters for both size and caching: each RUN/COPY/ADD produces a layer, and BuildKit reuses cached layers as long as the instruction and its inputs are unchanged, so you order Dockerfiles from least to most frequently changing.

A classic mistake is one RUN per shell command, producing dozens of layers and leaving package-manager caches baked into intermediate layers; chain related commands with && and clean up in the same RUN. Another is COPY . . before installing dependencies, which invalidates the dependency cache on every source edit. Interviewers often ask you to spot these problems in a sample Dockerfile, so practise reading one critically rather than just writing one.

# Ordered for cache efficiency: deps before source
FROM node:22-slim
WORKDIR /app

# Changes rarely -> cached across most builds
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Changes on every commit -> last
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
💡 Pro Tip: Be ready to answer 'which instructions create layers?' Filesystem-changing ones (RUN, COPY, ADD) do; metadata ones (CMD, ENTRYPOINT, ENV, EXPOSE, LABEL, WORKDIR, USER) only edit the config.
Q5

CMD vs ENTRYPOINT: how do they interact, and why does shell form break signal handling?

BasicDockerfile

Answer

ENTRYPOINT defines the executable that always runs; CMD supplies default arguments to it (or the whole default command if ENTRYPOINT is absent). When both are set in exec form, Docker concatenates them: ENTRYPOINT ["python", "app.py"] with CMD ["--port", "8000"] runs python app.py --port 8000, and anything passed after the image name in docker run replaces CMD but not ENTRYPOINT. To override ENTRYPOINT you need the explicit --entrypoint flag.

The form matters more than most candidates realise. Exec form (JSON array) runs your binary directly as PID 1. Shell form (CMD python app.py) actually runs /bin/sh -c "python app.py", so the shell is PID 1 and your app is its child.

The shell does not forward signals, so when docker stop sends SIGTERM to PID 1, your application never receives it, sits through the 10-second grace period, and gets SIGKILLed. That means no graceful shutdown: dropped in-flight requests, unflushed buffers, and databases that replay recovery logs on next start. The interview-grade answer: always use exec form for both instructions, and if your app spawns children or does not reap zombies, run docker run --init or use tini as the entrypoint so a minimal init handles signal forwarding and reaping. A common pattern is an entrypoint shell script that ends with exec "$@", which preserves exec-form semantics because exec replaces the shell with your real process.

# BAD: shell form; sh -c is PID 1, SIGTERM never reaches the app
CMD python app.py

# GOOD: exec form; python is PID 1 and receives SIGTERM
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]

# Entrypoint script pattern that keeps signals working
# entrypoint.sh:
#   #!/bin/sh
#   ./wait-for-db.sh
#   exec "$@"          <- exec replaces the shell
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]

Key Points

  • ENTRYPOINT = fixed executable; CMD = overridable default args
  • docker run args replace CMD; --entrypoint replaces ENTRYPOINT
  • Shell form wraps in /bin/sh -c and breaks SIGTERM delivery
  • exec "$@" in entrypoint scripts preserves signal handling
Q6

When should you use COPY instead of ADD, and what does ADD do that surprises people?

BasicDockerfile

Answer

Use COPY for everything unless you specifically need one of ADD's two extra behaviours, both of which are surprising enough that Docker's own best-practice docs tell you to prefer COPY. First, ADD with a local tar archive (tar, gzip, bzip2, xz) automatically extracts it into the destination directory instead of copying the file, which has bitten many teams that just wanted the archive itself inside the image. Second, ADD accepts a URL and downloads the file at build time, but it does so without any checksum verification by default, the download is not cached the way you would expect, and the fetched file lands in a layer before you can verify or clean it.

The modern replacement for URL downloads is either RUN curl with an explicit checksum check, or on current BuildKit, ADD with a --checksum=sha256:... flag, which at least pins the content. BuildKit also allows ADD of a git repository reference to clone at build time. In review-style interview rounds, an unexplained ADD is a mild red flag the interviewer will poke at: they want to hear that COPY is explicit and predictable, that both instructions create layers and honour .dockerignore, and that COPY additionally supports --from=<stage> in multi-stage builds to pull artifacts out of earlier stages and --chown/--chmod to set ownership without an extra RUN chown layer, which matters once you run as a non-root USER.

# Prefer COPY: explicit, no magic
COPY app.tar.gz /opt/         # copies the archive as-is
ADD app.tar.gz /opt/          # silently EXTRACTS it into /opt/

# Pinned remote download on modern BuildKit
ADD --checksum=sha256:24d5cba3... \
    https://example.com/tool-v2.1.0-linux-amd64.tar.gz /tmp/

# Multi-stage artifact copy with ownership
COPY --from=build --chown=app:app /src/dist /app/dist
Q7

What is the build context, why did your build 'send 2 GB to the daemon', and how does .dockerignore fix it?

BasicBuild

Answer

The build context is the directory tree you pass to docker build (the trailing dot in docker build -t app .). Before any instruction executes, the client packages that entire tree and streams it to the builder, because COPY and ADD can only reference files inside the context. If your project directory contains node_modules, a .git directory with years of history, local datasets, or previous build outputs, all of it gets tarred and transferred on every build even if no instruction copies it, which is where the classic 'transferring context: 2.1 GB' slowdown comes from.

On BuildKit the transfer is incremental after the first build, but the first build and CI builds (fresh checkout, no builder state) still pay the full price, and a bloated context can also invalidate the COPY . . cache layer through junk file changes. .dockerignore is the fix: a file at the context root with gitignore-like syntax that excludes paths from the context before transfer. A sensible baseline excludes .git, node_modules, dist/build outputs, logs, .env files, and any local secrets; excluding .env matters for security as much as size, because a stray COPY . . would otherwise bake secrets into an image layer. Two details worth stating in an interview: the Dockerfile itself does not have to live inside the context (use -f path/to/Dockerfile), and recent BuildKit supports per-Dockerfile ignore files named <Dockerfile-name>.dockerignore, useful in monorepos with several images built from one root context.

# .dockerignore at the context root
.git
node_modules
dist
coverage
*.log
.env
.env.*
Dockerfile*
docker-compose*.yml

# Build with an explicit Dockerfile outside/inside a monorepo
docker build -f services/api/Dockerfile -t api:dev .
💡 Pro Tip: In monorepos, keep the context as small as possible per service instead of always building from the repo root; CI minutes are usually the first place this shows up.
Q8

What does EXPOSE actually do, and how is it different from publishing ports with -p or -P?

BasicNetworking

Answer

EXPOSE is documentation plus metadata, nothing more. It records in the image config that the application inside listens on a given port (EXPOSE 8080/tcp), but it opens nothing on the host and performs no firewalling. Containers on the same Docker network can already reach each other on any port regardless of EXPOSE; inter-container traffic is not gated by it.

Actual publishing happens at runtime: -p 8080:80 maps host port 8080 to container port 80 via DNAT rules and the docker-proxy process, -p 127.0.0.1:5432:5432 restricts the binding to loopback, and -p 8080:80/udp publishes UDP. The capital -P flag publishes every EXPOSEd port onto random ephemeral host ports, which is the one place EXPOSE has a functional effect; docker port <container> then shows the assignments. Compose's ports: key is the equivalent of -p, while its expose: key is the equivalent of the Dockerfile instruction.

Two production notes that distinguish a strong answer: first, publishing on the default 0.0.0.0 has historically bypassed ufw/firewalld because Docker programs its own iptables chains (DOCKER / DOCKER-USER), so host firewall rules people think protect a port often do not; the supported fix is adding rules to the DOCKER-USER chain or binding to 127.0.0.1 and fronting with a reverse proxy. Second, when the container's process binds only to 127.0.0.1 inside the container, published ports will connect but immediately reset; the server must listen on 0.0.0.0 inside the container.

# Dockerfile: metadata only
EXPOSE 8080/tcp

# Runtime publishing
docker run -d -p 8080:80 nginx            # all host interfaces
docker run -d -p 127.0.0.1:5432:5432 postgres:16   # loopback only
docker run -d -P myapp                    # publish all EXPOSEd ports randomly
docker port <container>                   # see the mappings

# Compose equivalents
# ports:            <- published on the host
#   - "8080:80"
# expose:           <- documentation / inter-container only
#   - "8080"
Q9

Named volumes vs bind mounts vs tmpfs: when do you use each, and what happens to data on container removal?

BasicStorage

Answer

Named volumes are managed by Docker itself: created with docker volume create (or implicitly by -v pgdata:/var/lib/postgresql/data), stored under /var/lib/docker/volumes on Linux, and decoupled from any container lifecycle. Removing the container leaves the volume intact; only docker volume rm or docker volume prune deletes it (docker rm -v also removes anonymous volumes attached to that container). They are the right default for databases and anything stateful, and volume drivers let them live on NFS or cloud block storage.

Bind mounts map an arbitrary host path into the container (-v $(pwd)/src:/app/src or the more explicit --mount type=bind,src=...,dst=...). They are ideal for development hot-reloading, but they inherit host ownership and permissions, which causes the classic UID-mismatch pain on Linux, and on macOS they cross a VM boundary and are noticeably slower for IO-heavy workloads (VirtioFS improved this substantially over the old osxfs/gRPC-FUSE, but native volumes are still faster). tmpfs mounts (--tmpfs /tmp or --mount type=tmpfs) live purely in RAM: nothing touches disk, contents vanish when the container stops, and they are the right choice for scratch space and secrets you never want persisted. One subtlety interviewers like: if the image has content at the mount path, a first-time empty named volume gets pre-populated with that content, while a bind mount always shadows it with whatever the host directory contains, including nothing, which is exactly how the node_modules disappearing act happens in dev setups.

# Named volume: survives container removal
docker volume create pgdata
docker run -d --name db \
  -v pgdata:/var/lib/postgresql/data postgres:16

docker rm -f db          # volume still exists
docker volume inspect pgdata

# Bind mount for dev hot-reload (explicit --mount form)
docker run --mount type=bind,src=$(pwd)/src,dst=/app/src app:dev

# tmpfs for RAM-only scratch space
docker run --tmpfs /tmp:rw,size=64m app:prod

Key Points

  • Named volumes: Docker-managed, survive container removal, best for state
  • Bind mounts: host path mapping, dev-friendly, permission and speed gotchas
  • tmpfs: RAM-only, gone on stop, good for secrets and scratch data
  • Empty named volume gets seeded from image content; bind mount always shadows it
Q10

What is the difference between docker exec and docker attach, and how do you debug a running container with them?

BasicOperations

Answer

docker exec starts a brand-new process inside an existing container's namespaces, which is the everyday debugging tool: docker exec -it api /bin/sh drops you into a shell alongside the app without touching it. docker attach, by contrast, connects your terminal to the container's main process (PID 1), sharing its stdin/stdout/stderr. Attach has sharp edges: typing Ctrl+C in an attached session sends SIGINT to the application itself and will usually kill it, which surprises people expecting a detach; the safe escape is the detach sequence Ctrl+P Ctrl+Q, and only if the container was started with -it. In practice attach is rarely the right tool outside of interactive REPL-style containers; exec covers debugging.

Useful exec patterns worth naming: docker exec -it -u 0 app sh to get root even when the image runs as a non-root USER; docker exec -e DEBUG=1 app env to check environment; and docker exec app cat /proc/1/environ for what PID 1 actually received. The limitation that leads to a follow-up question: exec needs a shell or at least some binary present in the image, and distroless or scratch images have none, so exec fails with an OCI runtime 'exec: no such file or directory' error. The modern answers there are docker debug (a Docker Desktop feature that attaches a toolbox filesystem to any container) or running a second container that joins the target's namespaces, for example docker run --rm -it --pid=container:app --network container:app nicolaka/netshoot for network debugging.

# Everyday shell into a running container
docker exec -it api /bin/sh

# Root shell even if the image sets USER app
docker exec -it -u 0 api sh

# Attach to PID 1 (dangerous: Ctrl+C kills the app)
docker attach api
# Detach safely with: Ctrl+P then Ctrl+Q

# Debugging a distroless image that has no shell
docker run --rm -it \
  --pid=container:api --network=container:api \
  nicolaka/netshoot
💡 Pro Tip: Say explicitly that exec creates a new process while attach joins PID 1's stdio; that one sentence is the pass/fail line for this question.
Q11

How do image tags work, why is relying on :latest dangerous, and what do sha256 digests give you?

BasicImages & Registries

Answer

A tag is a mutable, human-readable pointer to an image manifest: nginx:1.27 is just a name in a registry that can be repointed at a different image at any time. :latest is not special beyond being the default when no tag is given; it does not mean newest, it means whatever was last pushed with that tag. The dangers are concrete. First, mutation: python:3.12 today and python:3.12 next month can be different images (patch releases, rebuilt base layers), so two environments that pulled at different times silently diverge, which destroys the 'it works on staging' guarantee.

Second, docker run does not re-pull an existing local tag, so your host may run a stale :latest while CI builds against a new one; conversely Kubernetes with imagePullPolicy: Always will pull a new one under you mid-incident. Digests fix this: every manifest is content-addressed, and referencing image@sha256:abc... is immutable forever, the registry can never serve different content for it. The production pattern interviewers want: build once, tag with something traceable (git SHA or CI run number), push, then deploy by digest or at least by the unique tag, never by a floating tag. For base images in Dockerfiles, teams pin FROM python:3.12-slim@sha256:... and let a bot like Renovate or Dependabot raise PRs when the digest updates, giving you reproducibility plus a controlled upgrade path. docker buildx imagetools inspect <ref> shows the manifest and digests behind any tag, including per-architecture entries in a multi-arch image.

# Tags are mutable pointers
docker pull nginx:1.27
docker images --digests nginx     # see the sha256 behind the tag

# Immutable reference: cannot change under you
docker pull nginx@sha256:6b06964cdbbc517102ce5e0cef95152f3c6a7ef703e4057cb574539de91f72e6

# Dockerfile: pinned base + traceable app tags
# FROM python:3.12-slim@sha256:<digest>
docker build -t registry.example.com/api:git-4f2a91c .
docker push registry.example.com/api:git-4f2a91c
Q12

How does docker logs work, which logging driver produces its output, and how do you stop logs from filling the disk?

BasicOperations

Answer

Docker captures whatever the container's PID 1 writes to stdout and stderr and hands it to a logging driver; docker logs simply reads that driver's storage back. The default driver is json-file, which writes newline-delimited JSON to /var/lib/docker/containers/<id>/<id>-json.log on the host. The trap: json-file has no rotation limits by default on a stock Engine install, so a chatty service in a debug loop can write tens of gigabytes and take the host down with a full root partition, a genuinely common production incident.

The fix is log rotation, either per container (--log-opt max-size=10m --log-opt max-file=3) or globally in /etc/docker/daemon.json under log-driver and log-opts, which applies to newly created containers only. The newer local driver stores compressed logs with sane defaults and is a better global choice when nothing external reads the JSON files directly. Alternative drivers ship logs off the host: awslogs to CloudWatch, fluentd, syslog, gelf, journald; the classic caveat is that with most non-default drivers docker logs stops working because there is nothing local to read, unless you enable dual logging (Docker keeps a local ring buffer alongside the remote driver, the default behaviour on modern Engine versions).

Everyday flags worth reciting: docker logs -f to follow, --tail 100, --since 15m, and -t for timestamps. Finally, applications in containers should log to stdout/stderr rather than files inside the container, per twelve-factor practice; log files in the writable layer bloat it and disappear with the container.

# Follow recent logs with timestamps
docker logs -f --tail 100 --since 15m -t api

# Per-container rotation
docker run -d \
  --log-driver json-file \
  --log-opt max-size=10m --log-opt max-file=3 \
  api:prod

# Global default: /etc/docker/daemon.json (affects NEW containers)
# {
#   "log-driver": "local",
#   "log-opts": { "max-size": "10m", "max-file": "3" }
# }
# then: sudo systemctl restart docker
💡 Pro Tip: The disk-full-from-unrotated-json-file incident is a favourite 'tell me about a production issue' scenario; know the daemon.json fix cold.
Q13

Explain the container lifecycle states and the four restart policies. When does unless-stopped beat always?

BasicOperations

Answer

A container moves through created (docker create: filesystem and config exist, no process), running, paused (docker pause freezes every process with the cgroup freezer, useful for momentarily suspending load), exited (the main process ended; the writable layer and logs remain inspectable), and finally removed (docker rm). docker start on an exited container reuses the same filesystem, which is handy for post-mortem debugging but also why crashed containers pile up in docker ps -a until pruned. Restart policies tell the daemon what to do when the main process exits or the daemon itself restarts. no is the default: nothing restarts. on-failure[:max-retries] restarts only when the exit code is non-zero, optionally capped (on-failure:5), and is the right choice for batch jobs that should retry a few times and then give up. always restarts on any exit, including exit 0, and, crucially, resurrects the container on daemon or host reboot even if an operator had manually stopped it before the reboot. unless-stopped is always minus that resurrection: a manual docker stop is remembered across daemon restarts, so a service an on-call engineer deliberately took down stays down after a host reboot. That is exactly why unless-stopped is the sane default for long-running services on a single host.

Two supporting facts strengthen the answer: restarts use an exponential backoff (starting around 100 ms and doubling) rather than a hot loop, and a container stuck restarting shows Restarting (137) in docker ps, at which point docker inspect --format '{{json .State}}' and docker logs --tail on the previous run are the first diagnostic steps. On orchestrators (Kubernetes, ECS, Swarm) the orchestrator owns restarts and these single-host policies should be left alone.

# Batch job: retry up to 5 times on non-zero exit
docker run --restart on-failure:5 etl-job:latest

# Long-running single-host service
docker run -d --restart unless-stopped --name api api:prod

# Operator takes it down; host reboots; it STAYS down
docker stop api

# Diagnose a restart loop
docker ps --filter name=api      # Restarting (137) 10 seconds ago
docker inspect --format '{{json .State}}' api
docker logs --tail 50 api
Q14

What problems does Docker Compose solve, and what changed between docker-compose v1 and the docker compose v2 plugin?

BasicCompose

Answer

Compose turns a multi-container setup (app, database, cache, queue) into one declarative YAML file, so 'clone the repo and run docker compose up' replaces a page of docker run commands with hand-managed networks and volumes. It creates a per-project network where services resolve each other by service name over built-in DNS, provisions named volumes, injects environment, and namespaces everything by project name so two checkouts do not collide. The v1 to v2 transition is a fair 2026 screening question. v1 was a separate Python binary invoked as docker-compose (with a hyphen); it reached end of life and was removed from Docker Desktop, so pipelines still calling docker-compose on fresh runners simply fail with command-not-found. v2 is a Go rewrite shipped as a CLI plugin: docker compose (a space), distributed with Docker Desktop and the docker-compose-plugin package on Linux.

Practical differences worth naming: the top-level version: key in the YAML is obsolete and ignored (the Compose Specification replaced the versioned 2.x/3.x schemas); the canonical filename is compose.yaml (docker-compose.yml still works); v2 changed container naming separators from underscores to hyphens (project-service-1); and v2 added genuinely useful subcommands and features over time, including docker compose watch for file-sync-and-rebuild dev loops, profiles, and healthcheck-aware depends_on. Compose is a single-host development and small-deployment tool; the same file's deploy: section only takes effect under Swarm, and for multi-host production you graduate to Swarm, Kubernetes, or a managed service like ECS, which can consume Compose files via translation tools.

# compose.yaml (no 'version:' key needed anymore)
services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10
volumes:
  pgdata:

# v2 CLI (space, not hyphen)
# docker compose up -d --build
# docker compose logs -f api
# docker compose down -v
Q15

ARG vs ENV in a Dockerfile: scope, precedence, and which one leaks into the final image?

BasicDockerfile

Answer

ARG defines a build-time variable, settable per build with --build-arg NAME=value; it exists only while the image is being built and is not present in the running container's environment. ENV defines a runtime environment variable baked into the image config; every container from that image starts with it, and it is also available to subsequent build instructions. Scope rules that trip people up: an ARG declared before the first FROM exists only for FROM lines (useful for parameterising the base image version) and must be re-declared after FROM to be visible inside the stage; in multi-stage builds each stage needs its own ARG re-declaration.

Precedence: docker run -e NAME=value overrides an ENV at container start, and when an ENV and ARG share a name the ENV wins during the build. The security angle is what interviewers actually probe: passing secrets as --build-arg is a well-known mistake, because ARG values are recorded in the image metadata and visible via docker history (and if the ARG is consumed by a RUN, the value can persist in that layer), so a token passed this way ships to everyone who can pull the image. The correct tools are BuildKit secret mounts (RUN --mount=type=secret) for build-time credentials and runtime injection (orchestrator secrets, env files excluded from the context) for run-time ones. A tidy closing point: predefined ARGs like TARGETARCH and TARGETPLATFORM are populated automatically by buildx and are the standard way to make one Dockerfile download the right binary per architecture.

# Parameterise the base image; ARG before FROM has special scope
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim

# Must re-declare to use inside the stage
ARG NODE_VERSION
RUN echo "building with node ${NODE_VERSION}"

# Runtime configuration
ENV NODE_ENV=production PORT=8080

# Build:  docker build --build-arg NODE_VERSION=20 -t app .
# Run:    docker run -e PORT=9090 app   (overrides the ENV)
# Audit:  docker history app            (ARG values visible here!)

Key Points

  • ARG = build-time only; ENV = baked into image, present at runtime
  • ARG before FROM needs re-declaration inside each stage
  • docker run -e overrides ENV; ENV overrides same-named ARG in build
  • Never pass secrets via --build-arg; docker history exposes them
Q16

How do you inspect running containers with docker ps, docker inspect, and docker stats, including --format Go templates?

BasicOperations

Answer

docker ps lists running containers (add -a for exited ones) and supports filters like --filter status=exited, --filter name=api, or --filter ancestor=nginx. docker inspect returns the full JSON state of a container or image: config, mounts, network settings, restart count, health status, OOMKilled flag, and exit code, and it is the first stop in almost any incident. docker stats streams live CPU, memory, network, and block IO per container, which is how you catch a container brushing against its memory limit before the OOM killer acts. The skill interviewers actually test is extracting exactly the field you need with Go templates instead of eyeballing JSON: docker inspect --format '{{.State.ExitCode}}' api, or '{{.NetworkSettings.Networks.mynet.IPAddress}}' for an IP on a user-defined network, or '{{json .State.Health}}' piped to jq for the recent healthcheck probes. The same templating works on ps and stats via --format 'table {{.Names}}\t{{.Status}}', which makes clean columnar output for scripts and runbooks.

A few high-signal one-liners to have memorised: docker ps -q --filter status=exited | xargs docker rm to clear dead containers; docker inspect --format '{{.State.OOMKilled}}' to confirm a 137 was really the OOM killer; docker inspect --format '{{.RestartCount}}' to quantify a crash loop; and docker stats --no-stream for a single snapshot usable in cron-driven monitoring scripts. Mentioning that inspect works on images, volumes, and networks too (docker volume inspect, docker network inspect) rounds out the answer and shows you treat it as the universal introspection tool rather than a container-only trick.

# Targeted state extraction
docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' api
docker inspect --format '{{json .State.Health}}' api | jq
docker inspect --format '{{.RestartCount}}' api

# Clean tabular listing
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

# One-shot resource snapshot (no live stream)
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}'

# Bulk cleanup of exited containers
docker ps -aq --filter status=exited | xargs -r docker rm
Q17

How do registries, docker pull, and docker push work, and how do Docker Hub rate limits break CI pipelines?

BasicImages & Registries

Answer

A registry is an HTTP service implementing the OCI Distribution API that stores manifests (JSON descriptions of an image) and blobs (the compressed layers and config, addressed by sha256). docker pull fetches the manifest for a tag, compares layer digests against local storage, and downloads only missing blobs in parallel; docker push does the reverse, uploading only blobs the registry lacks, which is why pushing a rebuilt image with one changed layer is fast. Image references decompose as registry/namespace/repo:tag; when the registry part is missing, docker.io (Docker Hub) is assumed, and library/ is the implicit namespace for official images, so nginx really means docker.io/library/nginx:latest. Alternatives every candidate should name: AWS ECR (ubiquitous with Indian startups on AWS; login via aws ecr get-login-password piped to docker login, and note the auth token expires after 12 hours, a classic cause of pipelines failing with 'no basic auth credentials'), Google Artifact Registry, GitHub Container Registry (ghcr.io), and self-hosted Harbor.

The rate-limit story is a real operational issue: Docker Hub throttles image pulls for anonymous and free accounts per a rolling window, and shared CI runners or NAT-ed office IPs exhaust the anonymous quota quickly, producing 429 'toomanyrequests: You have reached your pull rate limit' errors that look like flaky infrastructure. Mitigations: authenticate pulls in CI with a service account, run a pull-through cache (a local registry configured as a mirror, or ECR's pull-through cache rules), or copy frequently used base images into your own registry and reference them from there, which also insulates you from upstream tag mutations and outages.

# Fully qualified reference behind the shorthand
docker pull nginx            # == docker.io/library/nginx:latest

# ECR login (token valid ~12h; expiry breaks long-lived runners)
aws ecr get-login-password --region ap-south-1 \
  | docker login --username AWS --password-stdin \
    123456789012.dkr.ecr.ap-south-1.amazonaws.com

# Retag and push to a private registry
docker tag api:git-4f2a91c \
  123456789012.dkr.ecr.ap-south-1.amazonaws.com/api:git-4f2a91c
docker push 123456789012.dkr.ecr.ap-south-1.amazonaws.com/api:git-4f2a91c

# Pull-through mirror in /etc/docker/daemon.json
# { "registry-mirrors": ["https://mirror.internal.example.com"] }
Q18

Why does container-name DNS work on a user-defined bridge network but not on the default bridge?

BasicNetworking

Answer

The default bridge (docker0) is a legacy network kept for backwards compatibility, and it deliberately lacks the embedded DNS-based service discovery. Containers on it can reach each other by IP, but names do not resolve; the old workaround was --link, which is deprecated and should not appear in a 2026 answer except to say you would not use it. Any user-defined bridge (docker network create mynet) enables Docker's embedded DNS server, which containers reach at the fixed address 127.0.0.11 inside their network namespace.

On such a network, every container is resolvable by its --name, by network-scoped aliases (--network-alias), and Compose services resolve by service name because Compose always creates a user-defined network per project. User-defined bridges also give better isolation: containers on different user-defined networks cannot talk to each other at all unless a container is attached to both (docker network connect adds an interface for a second network), whereas everything dumped on the default bridge shares one flat segment. The debugging workflow to mention: docker network ls to list networks, docker network inspect mynet to see attached containers and their IPs, and exec-ing getent hosts db (better than ping in slim images, and works even when ICMP is blocked) to verify resolution. Common failure modes: two containers that cannot resolve each other are usually on different networks; a container started with plain docker run while the rest of the stack runs under Compose lands on the default bridge unless you pass --network <project>_default; and stale DNS after container recreation resolves itself because the embedded DNS tracks container lifecycle, unlike hand-edited /etc/hosts entries.

# Default bridge: name resolution FAILS
docker run -d --name db postgres:16
docker run --rm busybox getent hosts db      # no result

# User-defined bridge: embedded DNS at 127.0.0.11
docker network create appnet
docker run -d --name db --network appnet postgres:16
docker run --rm --network appnet busybox getent hosts db   # resolves

# Attach an existing container to a second network
docker network connect appnet legacy-worker

# Join a Compose project's network from a one-off container
docker run --rm -it --network myproject_default nicolaka/netshoot
💡 Pro Tip: One crisp sentence wins this question: 'the embedded DNS at 127.0.0.11 only exists on user-defined networks, which is why Compose service discovery works out of the box.'
Q19

What are dangling images, and how do docker system df and the prune commands reclaim disk safely?

BasicOperations

Answer

A dangling image is a layer stack with no tag pointing at it, shown as <none>:<none> in docker images. They accumulate naturally: every time you rebuild app:latest, the previous build's image loses its tag but stays on disk. Distinguish them from unused images, which still have tags but no container referencing them. docker system df is the triage command: it breaks disk usage into images, containers, local volumes, and build cache, with a RECLAIMABLE column; the build cache line surprises people because BuildKit's cache can quietly grow to tens of gigabytes on a busy build host.

The prune family, from safe to aggressive: docker image prune removes only dangling images; docker image prune -a removes all images not used by at least one container, which on a CI runner can wipe hours of cached base layers; docker container prune removes stopped containers; docker builder prune trims build cache and accepts --keep-storage 10GB to cap rather than empty it; docker volume prune removes volumes not attached to any container, and this is the genuinely dangerous one, because a database volume whose container was temporarily removed looks 'unused' and its deletion is real data loss. docker system prune combines container, network, image (dangling) and build-cache pruning; adding -a widens the image sweep and only --volumes includes volumes, a deliberate safety default. Production habits worth stating: run time-bounded prunes from cron on build hosts (--filter until=168h), never run --volumes pruning unattended, and remember that a running container pins its image, so pruning never breaks live workloads.

# Where is the disk going?
docker system df -v

# Safe routine cleanup: dangling images + stopped containers
docker image prune -f
docker container prune -f

# Cap BuildKit cache instead of nuking it
docker builder prune --keep-storage 10GB -f

# Time-bounded weekly sweep for CI hosts (no volumes!)
docker system prune -af --filter "until=168h"

# Volumes: only ever prune interactively, after reading the list
docker volume ls -f dangling=true
Q20

Describe the Docker client-daemon architecture. Why is mounting /var/run/docker.sock into a container equivalent to giving it root?

BasicArchitecture & Security

Answer

Docker is a client-server system. The docker CLI is a thin REST client; dockerd is the daemon that owns images, networks, and volumes and delegates container execution to containerd, which in turn invokes the OCI runtime runc to actually create namespaces and start processes. Client and daemon talk over the Unix socket /var/run/docker.sock by default (or TCP with TLS for remote daemons, and SSH transport via DOCKER_HOST=ssh://user@host, which is the sane way to control a remote engine).

Because dockerd runs as root, the socket is a root-equivalent capability: anyone who can write to it can create a container with --privileged, mount the host's / into it (-v /:/host), chroot in, read every secret, install a persistent backdoor, or manipulate any other container. That is why mounting docker.sock into a container, common in Jenkins agents and older CI patterns for docker-in-docker builds, must be treated as granting full host root to that workload and everything that can compromise it. If a build container gets code execution from a malicious pull request, the socket turns that into host takeover.

Mitigations to name: run builds with rootless alternatives (BuildKit rootless, kaniko, buildah) instead of the socket; use a socket proxy that whitelists only the API endpoints needed (for example tecnativa/docker-socket-proxy); run the daemon in rootless mode so the blast radius is a non-root user; or use dedicated ephemeral VM-based builders. Also worth stating: membership in the docker group on a host is equally root-equivalent for the same reason, something many teams forget when handing it out for convenience.

Key Points

  • CLI -> dockerd (REST over docker.sock) -> containerd -> runc
  • dockerd runs as root; socket access = root on the host
  • Socket-in-container turns any build compromise into host takeover
  • Safer builds: rootless BuildKit, kaniko, buildah, or a filtered socket proxy
  • docker group membership is root-equivalent too
💡 Pro Tip: If asked how you would exploit socket access, the canonical one-liner is: docker run -v /:/host --privileged -it alpine chroot /host. Knowing the attack makes the defence answer credible.
Q21

Alpine vs debian-slim vs distroless base images: how do you choose, and what breaks specifically on Alpine?

BasicImages & Layers

Answer

The choice is a trade-off between size, compatibility, and debuggability. Alpine is around 5 MB and uses musl libc plus BusyBox instead of glibc and GNU coreutils. That size is attractive, but musl is the source of real breakage: Python wheels are built against glibc (manylinux), so on Alpine pip frequently falls back to compiling C extensions from source, turning a 30-second install into a 10-minute build unless musllinux wheels exist; DNS resolution behaves differently (musl historically handled certain resolv.conf setups and parallel A/AAAA queries differently, causing intermittent lookup failures people misdiagnose as network flakiness); and Node.js on Alpine means running against musl, so native addons must be rebuilt.

Debian-slim variants (python:3.12-slim, node:22-slim) are modestly larger but glibc-based: prebuilt wheels and native modules just work, apt is available for the odd debugging tool, and the behaviour matches what most upstream software is tested on; for Python and Node services this is the safest default recommendation in an interview. Distroless images (gcr.io/distroless/*) go the other direction: glibc but no shell, no package manager, nothing except your runtime and its libraries, which shrinks the attack surface and CVE-scanner noise dramatically, at the cost of docker exec being useless (no /bin/sh) so debugging needs sidecar/nsenter techniques or docker debug. The pattern that ties it together: multi-stage builds let you compile on a fat image and ship on slim or distroless, and for static Go/Rust binaries FROM scratch is the logical extreme. Choose Alpine when you control the binaries (Go) and every MB matters; slim for interpreted runtimes; distroless when security review pressure outweighs shell-in-container convenience.

# Go: build fat, ship nearly nothing
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

# Python: slim beats alpine (glibc wheels install instantly)
# FROM python:3.12-slim
# RUN pip install --no-cache-dir -r requirements.txt

Key Points

  • Alpine = musl: wheel compilation, native-addon and DNS edge cases
  • debian-slim = glibc: safe default for Python/Node services
  • Distroless: no shell or package manager; small CVE surface, harder debugging
  • Multi-stage builds decouple the build image from the runtime image
Q22

What do WORKDIR and USER do, and how do you correctly run a containerised app as a non-root user?

BasicDockerfile

Answer

WORKDIR sets the working directory for every subsequent RUN, CMD, ENTRYPOINT, COPY, and ADD, creating the directory if needed; it replaces fragile RUN cd chains (cd in one RUN does not persist to the next, a classic beginner bug) and relative COPY destinations resolve against it. USER switches the user (and optionally group) for subsequent build instructions and, more importantly, for the container's runtime process. By default containers run as root, and although that root is constrained by capabilities and seccomp, it is still the wrong default: combined with a bind mount it writes root-owned files onto the host, and any container-escape vulnerability is immediately root on the host.

The correct pattern: create a dedicated user and group in the Dockerfile with explicit numeric IDs (so Kubernetes runAsNonRoot checks and host-side audits work predictably), chown only the directories the app must write (never chown the whole filesystem, which duplicates every touched file into a new layer and can double image size), and place USER after all the privileged setup steps like package installation. Use COPY --chown=app:app instead of a separate RUN chown to avoid that layer duplication. Runtime consequences to mention: non-root processes cannot bind ports below 1024 (bind to 8080 and publish -p 80:8080, or grant CAP_NET_BIND_SERVICE), and docker run --user 1001:1001 can override the image's USER, which some platforms (OpenShift, hardened Kubernetes PodSecurity profiles) do arbitrarily, so apps should not assume a specific UID or a writable home directory. Base images increasingly help: official Node images ship a node user, and distroless provides :nonroot variants.

FROM python:3.12-slim

# Privileged setup first, as root
RUN groupadd -g 10001 app && useradd -r -u 10001 -g app app
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# chown only what the app writes; avoid a blanket RUN chown layer
COPY --chown=app:app . .
RUN mkdir -p /app/tmp && chown app:app /app/tmp

USER app                      # numeric alternative: USER 10001:10001
EXPOSE 8080                   # >1024: non-root can bind it
CMD ["gunicorn", "-b", "0.0.0.0:8080", "main:app"]
Q23

How does the HEALTHCHECK instruction work, what states can a container be in, and what does Docker do (and not do) with an unhealthy container?

BasicOperations

Answer

HEALTHCHECK embeds a probe command in the image that the daemon runs inside the container on a schedule. Options: --interval (default 30s), --timeout (30s), --retries (3 consecutive failures before flipping to unhealthy), --start-period (grace window during which failures do not count against retries, essential for JVM apps or anything with slow warm-up), and on modern Engine versions --start-interval for faster probing during that startup window. The probe's exit code is the contract: 0 healthy, 1 unhealthy.

Health status shows in docker ps as (healthy) or (unhealthy) next to the status, and docker inspect --format '{{json .State.Health}}' exposes the last few probe results with their output, which is the first thing to read when a service flaps. The crucial nuance interviewers listen for: plain Docker does not restart or replace an unhealthy container; the status is passive information. It becomes actionable in three places: Compose, where depends_on with condition: service_healthy gates the start of dependent services; Swarm, which does replace unhealthy tasks and uses health during rolling updates; and external tools like autoheal that watch events and restart.

Implementation gotchas: the probe runs inside the container, so curl or wget must exist in the image (slim/distroless images often lack them; a tiny compiled healthcheck binary or the language's own stdlib client avoids installing curl); probes should be cheap and must not cascade (do not fail your health probe because a downstream dependency is down, or you convert one outage into many); and CMD-SHELL form needs a shell while CMD (exec) form does not. Kubernetes ignores Dockerfile HEALTHCHECK entirely in favour of its own liveness/readiness probes, a distinction worth volunteering.

FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci --omit=dev

# node stdlib probe: no curl needed in slim images
HEALTHCHECK --interval=10s --timeout=3s --retries=3 --start-period=20s \
  CMD ["node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]

CMD ["node", "server.js"]

# Reading health at runtime:
# docker ps                 -> Up 2 minutes (healthy)
# docker inspect --format '{{json .State.Health}}' api | jq
💡 Pro Tip: Volunteer that bare Docker never auto-restarts unhealthy containers; most candidates wrongly assume it does, and correcting that assumption unprompted scores points.
Q24

docker stop vs docker kill: signals, the grace period, and why some containers always take exactly 10 seconds to stop.

BasicOperations

Answer

docker stop sends SIGTERM to the container's PID 1, waits a grace period (10 seconds by default, tunable with docker stop -t 30 or per container via --stop-timeout), and then sends SIGKILL if the process is still alive. docker kill skips the courtesy and sends SIGKILL immediately (or any signal you choose: docker kill --signal SIGHUP nginx is a legitimate way to trigger nginx config reload). A container that always takes exactly the full grace period to stop is the diagnostic signature interviewers want you to recognise: its PID 1 never handled SIGTERM. The usual causes are shell-form CMD (sh -c is PID 1 and does not forward signals to the app), an application that simply lacks a SIGTERM handler, or a wrapper script that does not exec its child.

Consequences go beyond slow deploys: SIGKILL means no graceful shutdown, so in-flight HTTP requests drop, message-queue consumers do not ack or requeue cleanly, and databases perform crash recovery on next start. Exit codes encode the story: 143 is 128+15, clean death by SIGTERM; 137 is 128+9, death by SIGKILL, which follows either an expired stop timeout or the OOM killer (docker inspect's OOMKilled flag disambiguates the two). The fixes stack up: exec-form ENTRYPOINT so the real process is PID 1, actual signal handlers in the app (close the listener, drain requests, then exit 0), docker run --init or tini when PID 1 duties like signal forwarding and zombie reaping need an adult in the room, and a raised --stop-timeout for services that legitimately need longer drains. Also note the Dockerfile STOPSIGNAL instruction, which changes what docker stop sends, used by images whose upstream expects SIGQUIT (nginx) for graceful shutdown.

# Graceful stop with a longer drain window
docker stop -t 30 api          # SIGTERM, wait 30s, then SIGKILL

# Immediate kill / custom signal
docker kill api                # SIGKILL now
docker kill --signal SIGHUP nginx   # reload config, not death

# Node.js: an actual SIGTERM handler
# process.on('SIGTERM', async () => {
#   await server.close();      // stop accepting, drain in-flight
#   process.exit(0);           // -> exit code 143 path, clean
# });

# Dockerfile knobs
# STOPSIGNAL SIGQUIT
# then: docker run --init --stop-timeout 30 app
Q25

How does the build cache decide whether a layer is reused, and how do you order a Dockerfile to exploit it?

IntermediateBuild

Answer

The builder walks the Dockerfile top-down, and for each instruction asks: is there a cached layer produced by this exact instruction on top of this exact parent chain? For RUN, the comparison is the literal command string (plus the parent layers), not the command's output, so RUN apt-get update cached six months ago happily serves stale package lists forever; this is why apt-get update && apt-get install belong in one instruction. For COPY and ADD, BuildKit checksums the actual file contents and metadata of the copied paths, so a single changed source file invalidates that COPY and, critically, every instruction after it, because cache is invalidated from the first miss to the end of the stage.

That cascade rule dictates ordering: put the things that change rarely (base image, system packages, dependency manifests plus dependency install) before the things that change constantly (application source). The canonical pattern for any language: COPY only the lockfile first, install dependencies, then COPY the rest; editing business logic then rebuilds in seconds because the dependency layer is a cache hit. Other levers worth naming: ENV and ARG changes invalidate subsequent layers that consume them (an ARG whose value changes busts cache from its first use), so keep volatile build args late or out of the cacheable path; .dockerignore keeps junk changes from invalidating COPY . .; --no-cache forces a full rebuild and --pull refreshes the base image tag; and in multi-stage builds each stage caches independently, so a heavy toolchain stage stays cached while the final stage rebuilds. If a cache miss puzzles you, docker build --progress=plain shows exactly which steps were CACHED and where the chain broke.

# Cache-hostile (every code edit reinstalls dependencies)
# COPY . .
# RUN pip install -r requirements.txt

# Cache-friendly ordering
FROM python:3.12-slim
WORKDIR /app

COPY requirements.txt .           # busts only when deps change
RUN pip install --no-cache-dir -r requirements.txt

COPY . .                          # busts on code edits, cheap to redo
CMD ["python", "main.py"]

# Diagnostics
# docker build --progress=plain .     -> shows CACHED per step
# docker build --no-cache --pull .    -> full clean rebuild
💡 Pro Tip: Interviewers often show a Dockerfile with COPY . . at the top and ask why CI is slow. Name the invalidation cascade explicitly: first miss onwards, everything rebuilds.
Q26

Explain multi-stage builds: how they work, how --target is used in CI, and how they change what ships to production.

IntermediateBuild

Answer

A multi-stage Dockerfile contains several FROM instructions, each starting a new stage with its own base image; only the final stage becomes the output image, and COPY --from=<stage> cherry-picks artifacts across stages. The point is separating the build environment from the runtime environment: compilers, dev headers, npm devDependencies, and source code stay in the builder stage; the runtime stage receives only the compiled binary or the built assets. The wins are threefold: dramatically smaller images (a Go service drops from a 1 GB golang base to a few MB on distroless or scratch), a smaller attack and CVE surface because gcc and curl never ship, and no leaked source or build-time credentials in shipped layers (with the caveat that real secrets still belong in secret mounts, not earlier stages).

Stages are named with AS: FROM node:22 AS build, then COPY --from=build. You can also copy from an external image without a stage: COPY --from=nginx:1.27 /etc/nginx/nginx.conf /tmp/ is legal and occasionally handy for grabbing a binary like COPY --from=migrate/migrate /usr/local/bin/migrate. The --target flag builds up to a named stage and stops, which is how one Dockerfile serves several purposes in CI: a test stage that includes devDependencies and runs the suite (docker build --target test .), a dev stage with hot-reload tooling used by Compose (build: {target: dev}), and the default final stage for production. Under BuildKit, independent stages build in parallel and stages not needed for the target are skipped entirely, so a well-structured multi-stage file is also a build-speed optimisation, not just a size one.

# syntax=docker/dockerfile:1
FROM node:22-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

FROM deps AS test              # CI: docker build --target test .
COPY . .
RUN npm test

FROM node:22-slim AS runtime   # default final image
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Q27

What did BuildKit change compared to the legacy builder, and what are cache mounts, heredocs, and --output used for?

IntermediateBuild

Answer

BuildKit replaced the legacy builder as the default (on Linux since Docker Engine 23.0; Desktop earlier), and it is a rewrite, not a refactor. The legacy builder executed Dockerfiles strictly top-to-bottom; BuildKit builds a dependency graph, so independent multi-stage branches run in parallel, stages not required by the target are skipped, and only the files a stage actually needs are transferred from the context. On top of that graph it added capabilities the old builder simply could not express, unlocked by the # syntax=docker/dockerfile:1 directive.

Cache mounts are the highlight: RUN --mount=type=cache,target=/root/.cache/pip persists a package manager's download/compile cache across builds without baking it into any layer, which routinely cuts dependency-heavy rebuilds from minutes to seconds for pip, npm, Maven (~/.m2), Go (/go/pkg/mod), and apt (with sharing=locked to serialise concurrent access). Secret mounts (RUN --mount=type=secret) and SSH agent forwarding (--mount=type=ssh for private git dependencies) solved the build-time credential problem that ARG never could. Heredocs made multi-line RUN scripts and inline file creation readable: RUN <<EOF ...

EOF replaces && chains. Bind mounts (--mount=type=bind) let a RUN read context files without COPYing them into a layer at all. --output type=local,dest=./out exports a stage's filesystem to a host directory instead of an image, turning Docker into a hermetic cross-compilation tool, and docker build now also underpins buildx, which extends the same engine to multi-platform builds and remote cache backends. Two operational notes: cache mounts live in builder state, so docker builder prune clears them, and CI runners with ephemeral builders need remote cache (--cache-to/--cache-from) to benefit across jobs.

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .

# Cache mount: pip cache survives across builds, ships in no layer
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# Heredoc: readable multi-line RUN
RUN <<EOF
set -e
apt-get update
apt-get install -y --no-install-recommends curl
rm -rf /var/lib/apt/lists/*
EOF

# Export a stage to disk instead of an image:
# docker build --target build --output type=local,dest=./dist .
Q28

How do you get a private token into a build without leaking it into the image? Compare --build-arg with --mount=type=secret.

IntermediateBuild & Security

Answer

The requirement comes up constantly: pip/npm needs a private registry token, or go mod needs GitHub access, only during the build. The naive route, --build-arg NPM_TOKEN=..., is a leak: ARG values are baked into image metadata and readable with docker history --no-trunc by anyone who can pull the image, and if the value lands in a file during a RUN it also persists in that layer even if a later instruction deletes it (layers are additive; deletion is a whiteout, not removal). Slightly less naive is copying a credentials file in an early multi-stage stage and only shipping the final stage; that keeps the secret out of the shipped image but it still sits in the builder's cached layers on the CI host.

The correct mechanism is BuildKit secret mounts: docker build --secret id=npm_token,src=./token.txt (or env=NPM_TOKEN to read from the environment) makes the secret available inside exactly one RUN instruction as a tmpfs-mounted file, by default at /run/secrets/<id>. It never becomes a layer, never appears in history, and never enters the build cache key, so rotating the secret does not even bust the cache. The consuming RUN typically exports it inline: NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci, or writes a temporary .netrc within that same instruction.

The sibling feature for git dependencies is --ssh default with RUN --mount=type=ssh, which forwards the host's ssh-agent for that one instruction; nothing key-shaped touches the image. In an interview, close with the audit step: run docker history --no-trunc and a scanner like trivy against the final image to prove no credential survived, because 'I used the right flag' is weaker than 'and here is how I verified it'.

# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package*.json ./

# Secret exists only during this RUN, as a tmpfs file
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && \
    npm ci --omit=dev && \
    npm config delete //registry.npmjs.org/:_authToken

# Build invocations:
# docker build --secret id=npm_token,src=$HOME/.npm_token .
# docker build --secret id=npm_token,env=NPM_TOKEN .

# Private git deps via ssh-agent forwarding:
# RUN --mount=type=ssh git clone git@github.com:org/private-lib.git
# docker build --ssh default .
💡 Pro Tip: Say the phrase 'layers are additive, deleting in a later layer does not remove data from an earlier one'; it is the underlying reason every naive secret approach fails.
Q29

Compare the bridge, host, none, overlay, and macvlan network drivers. When is --network host actually the right call?

IntermediateNetworking

Answer

bridge is the single-host default: each container gets its own network namespace, a veth pair into a Linux bridge, and NAT to the outside; user-defined bridges add embedded DNS. host removes network isolation entirely, the container shares the host's network namespace, so it binds host ports directly: no veth, no NAT, no -p (publishing flags are silently ignored, a favourite trick question). none gives a namespace with only loopback, used for maximum isolation or when a sidecar-style tool will wire networking itself. overlay spans multiple hosts by encapsulating traffic in VXLAN; it is the basis of Swarm service networking and gives cross-node container-to-container connectivity with the same DNS semantics, at the cost of encapsulation overhead and an MTU that is smaller than the physical network's (the classic 1450-byte problem). macvlan (and its cousin ipvlan) attaches containers directly to the physical LAN with their own MAC/IP addresses, making them first-class citizens on the office or datacentre network; used for legacy appliances and network tooling, with the notable gotcha that the host cannot reach its own macvlan containers without a helper interface. When is host mode right? Three defensible cases: performance-critical services where NAT/veth overhead or huge port ranges matter (high-PPS proxies, VoIP/RTP media servers needing thousands of UDP ports, monitoring agents like node-exporter that must see host interfaces); software that must observe or manage the host network itself; and working around docker-proxy scaling limits when publishing hundreds of ports. The costs to state alongside: no port remapping, port conflicts with the host and other host-mode containers, weaker isolation, and on Docker Desktop (Mac/Windows) host networking historically did not behave like Linux because the 'host' is the hidden VM, so answers should be Linux-scoped.

# host: shares host namespace; -p is ignored
docker run --rm --network host node-exporter

# none: loopback only
docker run --rm --network none alpine ip addr

# macvlan on the physical LAN
docker network create -d macvlan \
  --subnet=192.168.1.0/24 --gateway=192.168.1.1 \
  -o parent=eth0 lan-net
docker run -d --network lan-net --ip 192.168.1.50 legacy-appliance

# overlay (Swarm) with encryption
docker network create -d overlay --opt encrypted app-mesh

Key Points

  • bridge: default, veth + NAT + embedded DNS on user-defined networks
  • host: no isolation, no -p, port conflicts possible; Linux-only semantics
  • overlay: VXLAN across hosts (Swarm); mind the reduced MTU
  • macvlan/ipvlan: containers appear as real LAN hosts; host-to-container quirk
Q30

How do Compose override files, profiles, and env_file interact, and what is the variable-precedence order when the same key is set in several places?

IntermediateCompose

Answer

Compose is designed for layering. By default it auto-loads compose.yaml plus compose.override.yaml, merging the override on top; teams commit a production-ish base and keep dev conveniences (bind mounts, exposed debug ports, build: instead of image:) in the override. Explicit layering with -f wins over the default: docker compose -f compose.yaml -f compose.prod.yaml up merges files left to right, later files overriding scalars and (for most keys) merging maps while list semantics vary (ports append, which can surprise you with duplicate publishes).

Profiles gate optional services: a service tagged profiles: ["debug"] simply does not exist unless you pass --profile debug or set COMPOSE_PROFILES, the clean way to keep pgadmin, mailhog, or load generators in the file without starting them for everyone. Environment handling has two distinct scopes people conflate: interpolation variables used inside the YAML itself (${IMAGE_TAG}), which come from the shell and from the .env file next to the compose file, and the container environment, which comes from environment: and env_file: keys. Precedence for what the container finally sees, highest first: values set with docker compose run -e, then environment: entries in the YAML (with shell values winning for entries written as pass-through names), then env_file: files in the order listed, then what is baked into the image via ENV.

And .env only feeds interpolation by default; it does not enter containers unless you also reference it via env_file or pass-through entries. Two operational commands close the answer: docker compose config prints the fully merged, interpolated model, the fastest way to debug 'why is this value wrong', and docker compose --env-file .env.staging config lets you swap interpolation sources per environment.

# compose.yaml (committed base)
services:
  api:
    image: registry.example.com/api:${IMAGE_TAG:-latest}
    environment:
      - LOG_LEVEL          # pass-through from the shell
    env_file:
      - .env.app
  pgadmin:
    image: dpage/pgadmin4
    profiles: ["debug"]     # only with --profile debug

# compose.override.yaml (auto-merged in dev)
services:
  api:
    build:
      context: .
      target: dev
    volumes:
      - ./src:/app/src

# Verify the merged result before blaming Compose:
# docker compose -f compose.yaml -f compose.prod.yaml config
# COMPOSE_PROFILES=debug docker compose up -d
Q31

How do --memory and --cpus limits actually behave under cgroups, and what happens the moment a container exceeds its memory limit?

IntermediateResources

Answer

docker run --memory 512m writes a hard limit into the container's cgroup (memory.max on cgroup v2, which every current distro uses). The kernel then treats the container's memory as a closed economy: page cache used by the container counts against it and is reclaimed first under pressure, and when reclaim cannot satisfy an allocation, the kernel OOM killer fires inside the cgroup, killing the largest process in it, usually your application, with SIGKILL. The container exits with code 137 and docker inspect shows OOMKilled: true; there is no warning, no exception in your logs, the process simply vanishes mid-request, which is why an app that 'randomly restarts with nothing in the logs' is an OOM story until proven otherwise.

Related flags: --memory-swap sets memory-plus-swap (equal values means no swap, the common production choice for predictable behaviour); --memory-reservation is a soft target used under host pressure; --oom-kill-disable is almost always a mistake because the allocating process then hangs instead. CPU is different in kind: --cpus 1.5 is a CFS bandwidth quota (150 ms of CPU time per 100 ms period), and exceeding it causes throttling, not killing; the process just runs slower, visible as latency spikes while docker stats shows CPU pinned at the cap. --cpu-shares is only a relative weight under contention, and --cpuset-cpus pins to specific cores. The senior-level closer: limits change what the app sees, not what it thinks it sees; runtimes that size heaps or thread pools from total host memory or CPU count must be told the truth (Java's MaxRAMPercentage, Node's --max-old-space-size, GOMAXPROCS/automaxprocs), because /proc/meminfo still reports host figures inside a container.

# Hard memory cap, no swap, 1.5 CPU quota
docker run -d --name api \
  --memory 512m --memory-swap 512m \
  --cpus 1.5 \
  api:prod

# Did the OOM killer strike?
docker inspect --format \
  'OOMKilled={{.State.OOMKilled}} Exit={{.State.ExitCode}}' api

# Watch pressure live
docker stats --no-stream api

# Compose (non-Swarm) equivalent:
# services:
#   api:
#     mem_limit: 512m
#     cpus: 1.5
Q32

A container died with exit code 137. Another shows 139, another 126. Decode the common container exit codes and the debugging path for each.

IntermediateDebugging

Answer

Container exit codes above 128 encode a fatal signal as 128 plus the signal number, and the low codes have shell conventions; reading them correctly shortcuts most incident triage. 137 = 128+9, SIGKILL: either the cgroup OOM killer (confirm with docker inspect --format '{{.State.OOMKilled}}', then raise the limit or fix the leak) or a docker stop whose grace period expired because PID 1 ignored SIGTERM (fix signal handling or raise --stop-timeout); the OOMKilled flag disambiguates, and on Kubernetes the same split appears as OOMKilled vs a failing preStop/terminationGracePeriod. 143 = 128+15, SIGTERM handled: a clean, expected shutdown during deploys; alarming only if it happens outside a deploy window, which suggests something is stopping your containers. 139 = 128+11, SIGSEGV: a segfault in native code, typical suspects being a C extension, a musl-vs-glibc mismatch on Alpine, or a corrupted native dependency cache; debugging means reproducing under the same base image and checking core dumps (ulimit and /proc/sys/kernel/core_pattern inside containers are host-governed). 126: the command exists but is not executable, usually a missing execute bit on an entrypoint script (fix with chmod +x before COPY or RUN chmod in the image) or an exec-format error from running an amd64 binary on arm64, increasingly common with M-series MacBooks building images for x86 servers; docker inspect --format '{{.Architecture}}' on the image versus uname -m reveals it. 127: command not found, typically a shell-form CMD referencing a binary absent from a slim image, a typo, or a script whose shebang points at /bin/bash in an image that only ships /bin/sh. The generic workflow: docker ps -a for the code, docker logs for last output, docker inspect .State for OOMKilled/Error, and docker events --since 1h for what the daemon did around that time.

Key Points

  • 137 = SIGKILL: OOM killer (check .State.OOMKilled) or stop-timeout expiry
  • 143 = SIGTERM: graceful shutdown; normal during deploys
  • 139 = SIGSEGV: native crash; suspect C extensions or musl/glibc mismatch
  • 126 = not executable: chmod +x missing, or amd64 binary on arm64
  • 127 = command not found: slim image lacks the binary, or bad shebang
  • Workflow: ps -a, logs, inspect .State, docker events --since 1h
💡 Pro Tip: Memorise 128+signal. Interviewers throw '137' at you precisely because it has two distinct causes, and choosing the right one requires the OOMKilled flag.
Q33

In a Node.js dev setup, bind-mounting the project directory wipes out node_modules from the image. Explain the anonymous-volume trick and the newer docker compose watch alternative.

IntermediateDevelopment Workflow

Answer

The failure sequence: the image runs npm ci during build, so /app/node_modules exists in the image; then Compose bind-mounts ./:/app for hot reload, and the mount shadows the entire /app from the image, including node_modules. If the host has no node_modules (or worse, one built on macOS with darwin-arm64 native binaries while the container is linux), the app crashes with MODULE_NOT_FOUND or invalid ELF header errors. The classic fix is an anonymous volume nested inside the bind mount: volumes: ["./:/app", "/app/node_modules"].

Mount resolution is longest-path-wins, so /app comes from the host but /app/node_modules is a Docker-managed volume, seeded on first use from the image's node_modules (empty-volume initialisation copies image content in). Caveats to volunteer: that anonymous volume persists across docker compose up runs, so after changing package.json you must recreate it (docker compose up --build -V, or docker compose down -v in dev) or you will debug stale dependencies; and it hides node_modules from the host, which upsets editors that want local type resolution unless you also npm install on the host. The modern alternative is docker compose watch: a develop.watch section on the service declares actions per path, sync copies changed source into the running container without any bind mount (sidestepping macOS filesystem-sharing overhead entirely), sync+restart restarts the process for config changes, and rebuild rebuilds the image when package.json changes, solving the stale-dependency problem structurally. On Linux, plain bind mounts remain fast and fine; the watch approach shines on Docker Desktop where VirtioFS, while much faster than the old osxfs, still lags native IO for dependency-heavy trees.

# Classic: bind mount + anonymous volume for node_modules
services:
  api:
    build: .
    volumes:
      - ./:/app
      - /app/node_modules      # longest path wins; image deps preserved
    command: npm run dev

# Modern: compose watch, no bind mount needed
services:
  api:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json

# Run with: docker compose watch
# After dep changes in the classic setup: docker compose up --build -V
Q34

Files written by a container to a bind mount show up as root-owned on the host, and the container cannot read files owned by your user. What is going on and what are the fixes?

IntermediateStorage & Permissions

Answer

Bind mounts do no ownership translation on native Linux: the kernel sees numeric UIDs, and both sides interpret them against their own /etc/passwd. A container process running as UID 0 writes files that the host sees as root-owned, which is why build outputs from a containerised toolchain suddenly need sudo to delete. Conversely, an image that declares USER app with UID 10001 cannot write to a mounted directory owned by host UID 1000.

Docker Desktop on macOS/Windows hides this because its file-sharing layer maps ownership, which is exactly why dev setups 'work on my Mac' and break on a Linux CI runner or a colleague's Ubuntu laptop. Fixes, in order of preference: run the container process with your host IDs at runtime, docker run --user $(id -u):$(id -g), the standard pattern for CLI-in-container tooling; the container user then needs no entry in the image's passwd file (some software grumbles about 'I have no name!' but works). In Compose, user: "${UID}:${GID}" with the values exported in the environment.

Second, build the image with matching IDs via build args (ARG UID=1000, useradd -u $UID), common for team dev images. Third, sidestep bind mounts for the writable paths: named volumes are owned inside Docker's world and seeded with the image's ownership, so databases should always use them rather than bind mounts partly for this reason. Fourth, for read-mostly mounts, loosen with :ro and keep writes in volumes. The heavyweight solutions, user namespace remapping (userns-remap) or rootless Docker, shift all container UIDs into a subordinate range and fix the root-files-on-host problem globally at the cost of some volume and networking friction, and are worth naming as the security-grade answer.

# Symptom on Linux:
# $ ls -l output/
# -rw-r--r-- 1 root root 1048576 build.tar   <- needs sudo to remove

# Fix 1: run as your host UID/GID
docker run --rm -v $(pwd):/work -w /work \
  --user $(id -u):$(id -g) node:22-slim npm run build

# Fix 2: bake matching IDs at build time
# ARG UID=1000
# ARG GID=1000
# RUN groupadd -g $GID app && useradd -u $UID -g app app
# docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) .

# Compose:
# services:
#   api:
#     user: "${UID:-1000}:${GID:-1000}"
#     volumes: ["./:/app"]
💡 Pro Tip: Explicitly contrast Docker Desktop's ownership mapping with native Linux; recognising why the bug only appears on Linux is what marks real experience.
Q35

Your Python service image is 1.6 GB. Give a systematic checklist to shrink it and the tools you would use to find the bloat.

IntermediateImages & Layers

Answer

Work from measurement, not guesswork: docker history --no-trunc app:latest shows the size each instruction added, and the dive tool (or docker scout's image analysis) walks layer contents interactively, immediately revealing the 400 MB of apt lists, test datasets, or .git directory someone COPYed in. The checklist, roughly in impact order. First, base image: python:3.12 is Debian with build tooling at close to 1 GB; python:3.12-slim drops most of it.

Second, multi-stage: if you need gcc to compile wheels (psycopg2, numpy on odd platforms), do it in a builder stage and copy site-packages or a virtualenv into a slim runtime stage; compilers never ship. Third, single-instruction hygiene: apt-get update && apt-get install -y --no-install-recommends ... && rm -rf /var/lib/apt/lists/* must be one RUN, because deletion in a later layer hides files without reclaiming space; the same logic applies to pip install --no-cache-dir (or a BuildKit cache mount, which keeps the cache out of layers entirely while still speeding rebuilds). Fourth, .dockerignore: keep .git, tests, datasets, and local venvs out of COPY . . entirely.

Fifth, purge byte-code and docs if you are chasing the last tens of MB (find -name __pycache__ in the same RUN that creates them). Sixth, question every 'debugging convenience' package; curl, vim, and build-essential in production images are both bloat and attack surface. Practical outcomes to quote: a typical Flask/FastAPI service lands at 150-250 MB on slim with multi-stage, versus well over 1 GB naive. Finally note what size does and does not buy: smaller images pull faster (cold-start, autoscaling, node provisioning) and scan cleaner, but shared base layers are downloaded once per host, so standardising the org on one pinned base image is itself a size optimisation across the fleet.

# Find the bloat first
docker history --no-trunc app:latest
# or: dive app:latest

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --prefix=/install -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=build /install /usr/local
COPY app/ ./app/
USER 10001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Q36

Why is docker commit considered an anti-pattern for producing images, and where does it still have legitimate uses?

IntermediateImages & Layers

Answer

docker commit snapshots a container's writable layer into a new image: whatever state the container has accumulated, apt installs, edited configs, temp files, becomes an image layer. The reasons it is an anti-pattern for anything you intend to ship are about reproducibility and auditability. There is no recipe: a Dockerfile is executable documentation that code review can inspect, CI can rebuild, and a scanner can reason about; a committed image is an opaque blob whose contents depend on what a human typed into a shell, in what order, on which day.

You cannot rebuild it against a patched base image, which matters constantly: when the next OpenSSL CVE lands, Dockerfile-built images are rebuilt by a pipeline in minutes, while committed images require someone to remember what was done. It captures garbage: shell history, package caches, stray editor backups, and, dangerously, any credentials that were present in the filesystem at commit time. It also encourages drift culture, the container-era equivalent of snowflake servers that configuration management spent a decade eliminating.

Legitimate uses are narrow and real: forensics, committing a compromised or misbehaving container to preserve its exact state for offline analysis (docker commit, then docker save to move the evidence); debugging experiments where you want to checkpoint an interactive exploration before trying something destructive; and salvaging work from an interactive session before writing the proper Dockerfile. In an interview, the strong close is process-shaped: if someone must hotfix a running container in an emergency, the rule is that the same change lands in the Dockerfile and goes through the pipeline the same day, and docker diff <container> is the tool that shows exactly what was changed by hand and therefore what must be codified.

# What did someone change by hand in this container?
docker diff prod-api-1
# C /etc/nginx
# A /etc/nginx/conf.d/hotfix.conf

# Forensics: preserve exact state as evidence
docker commit --pause prod-api-1 evidence/api-incident-2026-08-11
docker save evidence/api-incident-2026-08-11 -o incident.tar

# The discipline: the same fix must become code
# (Dockerfile change -> PR -> CI build -> redeploy)
Q37

How do you back up and restore a named volume, and how do you move one between hosts?

IntermediateStorage

Answer

Docker has no first-class volume backup command, so the standard technique is a throwaway container that mounts both the volume and a host directory, then tars one into the other. Backup: docker run --rm -v pgdata:/from -v $(pwd):/to alpine tar czf /to/pgdata.tar.gz -C /from . Restore is the mirror image into a fresh volume.

This works because volumes are just directories under Docker's management; the temp container is only there to get a process that can see both filesystems. The critical correctness point interviewers listen for: for databases, a file-level copy of a live volume is not a safe backup, because you can capture a torn state mid-write. Either stop the container first, use the engine's own dump tooling (pg_dump/pg_basebackup, mysqldump/xtrabackup) exec'd against the running instance, or snapshot at the storage layer (LVM/EBS snapshots) with the database quiesced.

A pragmatic pattern is a cron-driven sidecar container on the same network running pg_dump nightly to a mounted backup path shipped off-host. Moving a volume between hosts is the same tar dance plus transport: tar on host A, scp/rclone to host B, restore into a new volume there; for repeated syncs, rsync between mounted temp containers avoids full copies. Also worth naming: docker volume create supports driver options, so an NFS-backed volume (driver local with type=nfs options) makes 'moving' unnecessary for shared-storage cases; docker cp can pull files out of a container but reads through the container filesystem and is not a volume tool per se; and docker save/load is strictly for images, never volumes, a confusion this question is often designed to expose.

# Backup a named volume to a tarball
docker run --rm \
  -v pgdata:/from:ro -v $(pwd)/backup:/to \
  alpine tar czf /to/pgdata-$(date +%F).tar.gz -C /from .

# Restore into a fresh volume
docker volume create pgdata_restored
docker run --rm \
  -v pgdata_restored:/to -v $(pwd)/backup:/from \
  alpine sh -c 'tar xzf /from/pgdata-2026-08-11.tar.gz -C /to'

# Database-consistent alternative (run against live postgres)
docker exec db pg_dump -U app -Fc app > app-$(date +%F).dump

# NFS-backed volume: sharing instead of moving
docker volume create --driver local \
  --opt type=nfs --opt o=addr=10.0.0.5,rw \
  --opt device=:/exports/pgdata pgdata_nfs
💡 Pro Tip: Always add the consistency caveat unprompted: tarring a live database volume is a corruption lottery. That one sentence separates ops experience from tutorial knowledge.
Q38

Your team builds on M-series MacBooks but deploys to amd64 servers (and some Graviton nodes). How do multi-arch builds with buildx and manifest lists work?

IntermediateBuild & Platforms

Answer

The failure this solves: an image built natively on an M-series Mac is linux/arm64; deployed to an amd64 server it dies instantly with 'exec format error' (or exit 126/'no matching manifest' at pull time). The reverse bites too: pulling amd64-only images on Apple silicon runs them under emulation with a platform-mismatch warning and a hefty speed penalty. The mechanism that fixes it is the manifest list (OCI image index): a tag points not at one image but at an index of per-platform manifests, and each docker pull transparently selects the entry matching the client's OS/architecture.

That is why python:3.12 'just works' on both your Mac and your servers, it is actually several images behind one tag. You produce these with docker buildx build --platform linux/amd64,linux/arm64 -t repo/app:tag --push .; buildx builds each platform (cross-compiling natively where the toolchain allows, otherwise executing foreign-arch build steps under QEMU binfmt emulation, which works but is several times slower for compile-heavy builds) and pushes a combined index. Note --push directly to a registry is the usual flow because the classic local image store historically held a single platform; --load pulls one platform back locally (the containerd image store lifts this restriction on newer setups).

Dockerfiles cooperate via automatic build args: TARGETARCH/TARGETPLATFORM let one Dockerfile fetch the right binary per platform, and FROM --platform=$BUILDPLATFORM plus cross-compilation (trivial in Go with GOARCH=$TARGETARCH) avoids QEMU entirely for the expensive stage. For CI at scale, dedicated native builders per architecture joined into one buildx builder beat emulation. Verification: docker buildx imagetools inspect repo/app:tag lists every platform in the index; the arm64 entry is what your Graviton and Mac users pull, and its absence explains a whole genre of 'works locally' tickets.

# One-time builder setup
docker buildx create --name multi --use
docker run --privileged --rm tonistiigi/binfmt --install all  # QEMU

# Build and push amd64 + arm64 behind one tag
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/api:git-4f2a91c --push .

# Dockerfile: cross-compile Go natively, skip QEMU
# FROM --platform=$BUILDPLATFORM golang:1.23 AS build
# ARG TARGETARCH
# RUN CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /out/server .

# Verify the index
docker buildx imagetools inspect registry.example.com/api:git-4f2a91c
Q39

CI builds are slow because every runner starts with an empty build cache. Compare the cache-from/cache-to backends (inline, registry, gha) and mode=min vs mode=max.

IntermediateCI/CD

Answer

Ephemeral CI runners defeat local layer caching, so BuildKit supports exporting cache to shared storage and importing it in the next job via --cache-to and --cache-from. Inline cache (--cache-to type=inline) embeds cache metadata into the pushed image itself; the next build does --cache-from type=registry,ref=repo/app:latest and reuses matching layers. It is the simplest setup but only records the final stage's layers (effectively mode=min), so multi-stage builds get poor hit rates: the expensive builder stage is exactly what inline cache forgets.

Registry cache (--cache-to type=registry,ref=repo/app:buildcache,mode=max) writes a dedicated cache artifact to the registry; mode=max includes intermediate and multi-stage layers, which is what you want for real Dockerfiles, at the cost of more registry storage and push time. GitHub Actions has a native backend, type=gha, which stores cache in the Actions cache service; it is convenient with docker/build-push-action but subject to the repository cache quota and eviction, so busy repos still fall back to registry cache for reliability. There is also type=local for persistent-disk runners and type=s3 for roll-your-own storage.

Beyond backends, the answer should include the hygiene that makes caches hit at all: deterministic Dockerfile ordering (lockfile-first COPY), pinning the base image so a moving tag does not invalidate everything, and cache mounts for package managers, remembering that RUN --mount=type=cache contents are part of builder state, not layers, so they only persist across CI jobs if the backend or runner disk retains builder state. A concrete shape to quote: a Node monorepo build dropping from 8-10 minutes cold to under 2 with registry cache mode=max plus npm cache mounts is a typical, believable win, and the follow-up question 'why did the cache miss anyway' is answered with --progress=plain and checking which COPY checksum changed.

# Registry cache, full multi-stage coverage
docker buildx build \
  --cache-from type=registry,ref=registry.example.com/api:buildcache \
  --cache-to   type=registry,ref=registry.example.com/api:buildcache,mode=max \
  -t registry.example.com/api:git-$GIT_SHA --push .

# GitHub Actions backend
# - uses: docker/build-push-action@v6
#   with:
#     push: true
#     tags: registry.example.com/api:${{ github.sha }}
#     cache-from: type=gha
#     cache-to: type=gha,mode=max

# Diagnose misses
# docker buildx build --progress=plain . 2>&1 | grep -n CACHED
Q40

Where should database migrations and 'wait until Postgres is ready' logic live in a Dockerised deployment?

IntermediateOperations

Answer

The naive answers all have failure modes worth naming before giving the good ones. Running migrations in the Dockerfile is wrong outright: builds must not touch environments, and the image would be coupled to one database's state. Running them unconditionally in the entrypoint script of the app container works for a single instance but breaks the moment you scale: three replicas racing to migrate the same schema, with two failing or, worse, interleaving.

Sleep-based waiting (sleep 10 && start) is the flakiness generator everyone has to confess to eventually. The defensible patterns: first, a dedicated migration step in the deploy pipeline, docker run --rm the same app image with a migrate command (or docker compose run --rm api npm run migrate) before rolling the new app version; one runner, explicit logs, deploy halts if it fails. Second, in Compose, model it as a short-lived service: migrate service runs the migration and exits, and the app service uses depends_on: {migrate: {condition: service_completed_successfully}}, while migrate itself gates on the database with condition: service_healthy against a pg_isready healthcheck; this encodes the whole ordering declaratively.

(On Kubernetes the same role is played by a Job or, with locking caveats, an init container.) For readiness waiting inside entrypoints when you must, loop on a real check, pg_isready or a psql SELECT 1, with a bounded retry count, not a sleep; better still, make the application itself retry its initial DB connection with backoff, because in production the database can also go away after startup, and an app that only handles missing-DB-at-boot is half-built. Migration tools with advisory locks (Flyway, golang-migrate, Prisma, Django with locking wrappers) add a second line of defence against concurrent runners, worth mentioning as the belt to the pipeline's braces.

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      retries: 12

  migrate:
    image: registry.example.com/api:${TAG}
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  api:
    image: registry.example.com/api:${TAG}
    depends_on:
      migrate:
        condition: service_completed_successfully

# Pipeline alternative:
# docker run --rm --network prod_default \
#   registry.example.com/api:$TAG npm run migrate
Q41

What are Docker contexts, and how do you run containers on a remote server over SSH without exposing the daemon on TCP?

IntermediateOperations

Answer

A context bundles everything the CLI needs to talk to one engine: the endpoint (unix socket, ssh://, or tcp:// with TLS), and a name to switch by. docker context create staging --docker host=ssh://deploy@staging.example.com registers a remote engine; docker context use staging repoints every subsequent docker and docker compose command at it, and docker --context staging ps targets it for one command without switching. Under ssh://, the CLI tunnels the API over your existing SSH session, so authentication is your SSH key, transport is encrypted, and nothing new listens on the network. That matters because the alternative people historically used, exposing dockerd on tcp://0.0.0.0:2375 without TLS, is a famous own-goal: the API is unauthenticated root, and internet-scanning botnets find and cryptojack open 2375 ports within minutes; even 2376-with-TLS requires real certificate management to be safe.

SSH contexts make the whole class of problem disappear for small-scale ops. Practical uses that come up in interviews: deploying Compose stacks to a single VPS (docker --context prod compose up -d is a legitimate minimal deployment story for small products and internal tools); building on a beefy remote machine from a laptop (combine with docker buildx create --name remote ssh://build@bigbox to get remote BuildKit builders); and separating environments so a fat-fingered docker system prune hits the context you think it does, which is also the argument for naming contexts loudly (prod-mumbai, not ctx2). Caveats worth adding: bind mounts resolve against the remote host's filesystem, not your laptop's, a routine surprise; the remote user needs docker group membership or rootless setup; and long image transfers go over SSH, so pushing to a registry and pulling remotely usually beats docker save piping for anything large.

# Register and use a remote engine over SSH
docker context create prod-mumbai \
  --docker host=ssh://deploy@10.20.4.11

docker context ls
docker --context prod-mumbai ps            # one-off
docker context use prod-mumbai             # sticky switch

# Deploy a Compose stack to the remote host
docker --context prod-mumbai compose -f compose.prod.yaml up -d

# Remote BuildKit builder on a big machine
docker buildx create --name bigbox ssh://build@10.20.4.20 --use

# Back to local
docker context use default
💡 Pro Tip: If asked about tcp://2375, the only right answer is 'never without TLS, and rarely even then; use ssh:// contexts'. Mentioning the cryptojacking scans shows operational awareness.
Q42

Harden a container at runtime: what do --cap-drop, --security-opt no-new-privileges, --read-only, and seccomp actually remove?

IntermediateSecurity

Answer

Default Docker is moderately confined already: containers run with a reduced capability set (a couple of dozen retained out of the kernel's full list), the default seccomp profile blocks 40-plus syscalls (kexec, mount tricks, most of the ptrace family historically), and an AppArmor/SELinux profile applies where the distro supports it. Hardening means shrinking from there toward least privilege. --cap-drop ALL removes every capability, then --cap-add returns only what the workload proves it needs; a typical web service needs none at all once it binds a port above 1024, and needing NET_BIND_SERVICE for port 80 or CHOWN for entrypoint chown-ing is an explicit, reviewable statement. The capabilities that should trigger scrutiny in review: SYS_ADMIN (a grab-bag so broad it is near-root), NET_ADMIN, SYS_PTRACE, and DAC_OVERRIDE. --security-opt no-new-privileges:true sets the kernel's no_new_privs bit, so setuid binaries and file capabilities cannot elevate the process after start; it kills the classic 'find a setuid binary inside the container' escalation path and has almost no legitimate downside for services. --read-only mounts the root filesystem read-only, so malware cannot drop payloads or modify binaries in place; pair it with --tmpfs /tmp and targeted volumes for the few paths that genuinely need writes, and you have also implicitly documented your app's write surface.

And --privileged is the anti-flag: it grants all capabilities, disables seccomp and AppArmor, and exposes host devices; the honest description is 'root on the host with extra steps', acceptable for things like DinD in ephemeral CI VMs and effectively nowhere else. Compose spells these cap_drop, security_opt, read_only, tmpfs. The closing move in an interview is composing them into a paranoid-by-default runtime block and stating that the point is blast-radius reduction: any single RCE in the app should strand the attacker in a filesystem they cannot write, with no capabilities and no path to more.

docker run -d --name api \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges:true \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  -v app-uploads:/app/uploads \
  --user 10001:10001 \
  --memory 512m --pids-limit 256 \
  api:prod

# Compose:
# services:
#   api:
#     cap_drop: [ALL]
#     cap_add: [NET_BIND_SERVICE]
#     security_opt: ["no-new-privileges:true"]
#     read_only: true
#     tmpfs: ["/tmp"]

Key Points

  • --cap-drop ALL then add back the provable minimum
  • no-new-privileges blocks setuid/file-capability escalation
  • --read-only + tmpfs documents and enforces the write surface
  • --privileged disables seccomp/AppArmor and is root-equivalent
  • --pids-limit contains fork bombs; pair with memory limits
Q43

How do you scan images for vulnerabilities in CI with trivy or docker scout, and what do you do about the unfixable CVE noise?

IntermediateSecurity

Answer

A scanner unpacks the image, inventories OS packages (dpkg/apk databases) and language dependencies (package-lock.json, poetry.lock, go.sum, JAR manifests), and matches versions against vulnerability databases. Trivy is the de facto open-source standard: trivy image app:tag runs locally or in CI, --severity HIGH,CRITICAL focuses output, --exit-code 1 turns findings into build failures, and --ignore-unfixed suppresses CVEs that have no patched package version yet, which is the single most important flag for keeping the gate actionable. Docker Scout is Docker's integrated equivalent: docker scout cves for listing, docker scout recommendations for concrete base-image upgrade suggestions ('moving to debian:bookworm-2026xxxx removes N CVEs'), plus registry integration that watches pushed images continuously, catching the CVEs disclosed after you shipped, which a push-time-only scan never will.

The process questions matter more than the tool flags. Gate sensibly: failing every build on any HIGH turns the scanner off socially within a month because developers cannot fix a CVE in glibc; a workable policy fails on fixable HIGH/CRITICAL, tracks the rest, and uses a reviewed .trivyignore with expiry comments for accepted risks. Reduce the denominator structurally: slim or distroless bases cut hundreds of findings by containing fewer packages, and multi-stage builds keep compilers out of scan scope.

Rebuild cadence beats heroics: most fixes arrive by rebuilding against a patched base, so weekly scheduled rebuilds of service images (with pinned-digest bumps via Renovate) clear the queue mechanically. Finally connect scanning to provenance: generating an SBOM at build time (trivy or buildx attestations) means that when the next Log4j-class disclosure lands, you query which images contain the package instead of rescanning the fleet in a panic, which is precisely the story an interviewer wants for 'how did you handle a vulnerability disclosure'.

# CI gate: fail only on actionable findings
trivy image \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  --exit-code 1 \
  registry.example.com/api:git-$GIT_SHA

# SBOM at build time (queryable later)
trivy image --format cyclonedx --output sbom.cdx.json api:latest

# Docker Scout: what should we upgrade to?
docker scout cves api:latest
docker scout recommendations api:latest

# .trivyignore (reviewed, with expiry discipline)
# CVE-2026-XXXXX  # accepted: dev-only tool, remove after base bump
Q44

How does Testcontainers change integration testing compared to a shared test database, and what does it need from the Docker environment in CI?

IntermediateTesting

Answer

Testcontainers is a set of libraries (mature for Java, Go, Node, Python, .NET) that start real dependencies, Postgres, Redis, Kafka, Elasticsearch, as throwaway containers from inside your test code, wait for readiness, hand you connection details, and destroy everything afterwards. The contrast it replaces: a shared long-lived test database, with its polluted state, cross-team collisions, 'works alone, fails in the suite' ordering bugs, and drift from production versions; or mocking the database entirely, which silently exempts your SQL, migrations, and transaction semantics from testing. With Testcontainers each test class (or suite) gets a pristine postgres:16 matching production's major version, ports are randomly published to avoid collisions, and parallel CI shards cannot interfere with each other.

Readiness is handled by wait strategies (log patterns, port checks, health endpoints) rather than sleeps, and the sidecar Ryuk container reaps leftovers even when the test process is killed, which is what keeps CI hosts from accumulating orphaned containers. What it needs from the environment is where interviews go next: locally, just a working Docker socket. In CI, the options ranked: a runner with its own Docker daemon (VM-based runners, or GitHub-hosted runners which include one) is simplest; mounting the host's docker.sock into a containerised CI job works but grants the job root-equivalent host access and containers become siblings, so testcontainers' host networking assumptions need TESTCONTAINERS_HOST_OVERRIDE in some setups; Docker-in-Docker (docker:dind) requires --privileged, which is its own security discussion; and Testcontainers Cloud outsources the daemon entirely. Cost control is the last mark of experience: reuse containers across tests within a suite where isolation allows, pin small images, and pre-pull heavy ones (kafka) in the runner image so test time is not dominated by pulls.

// Node.js + @testcontainers/postgresql
import { PostgreSqlContainer } from '@testcontainers/postgresql';

describe('orders repository', () => {
  let pg, pool;

  beforeAll(async () => {
    pg = await new PostgreSqlContainer('postgres:16')
      .withDatabase('app_test')
      .start();                       // waits for readiness, random port
    pool = makePool(pg.getConnectionUri());
    await runMigrations(pool);        // real migrations, real SQL
  }, 60_000);

  afterAll(async () => {
    await pool.end();
    await pg.stop();                  // Ryuk reaps strays regardless
  });

  it('persists an order atomically', async () => {
    await createOrder(pool, { sku: 'A1', qty: 2 });
    const rows = await pool.query('SELECT count(*) FROM orders');
    expect(rows.rows[0].count).toBe('1');
  });
});
Q45

How does docker compose up --scale api=3 behave, why does it collide with published ports, and how do you load-balance the replicas?

IntermediateCompose & Scaling

Answer

--scale api=3 (or a deploy.replicas value consumed by some tooling) makes Compose run three containers of the service, named project-api-1 through -3, all attached to the project network. Two behaviours follow immediately. First, the port collision: if the service publishes ports: ["8080:8080"], only one container can bind host port 8080, so replicas 2 and 3 fail with 'port is already allocated'.

Fixes: publish a range ("8080-8082:8080" maps each replica to a distinct host port), publish only the ephemeral side ("8080" alone lets Docker pick random host ports, discoverable via docker compose port), or, the production-shaped answer, stop publishing app ports at all and put a reverse proxy in front. Second, the load balancing that already exists: Docker's embedded DNS returns the IPs of all replicas for the service name, in rotating order, so another container resolving http://api:8080 gets DNS round-robin for free. Its limits are worth stating: DNS caching in clients (the JVM famously caches lookups; Node keeps connections alive) skews distribution, there is no health-awareness, and a resolved-then-died IP produces connection errors until re-resolution.

Hence the proxy pattern: an nginx or, more dynamically, Traefik container publishes 80/443 and forwards to the service name; Traefik specifically watches the Docker socket (read-only, ideally through a socket proxy) and discovers replicas by container label, adding and removing backends as you scale, with per-backend health checks. The honest scoping statement that closes the answer well: Compose scaling is single-host concurrency, useful for soaking one box or local testing of statelessness; the moment you need multi-host scheduling, health-driven replacement, and rolling deploys as first-class features, that is Swarm or Kubernetes territory, and pretending Compose does that job is how people end up reinventing an orchestrator in bash.

# The collision
# services:
#   api:
#     ports: ["8080:8080"]    <- replica 2 fails: port already allocated

# Working setup: proxy publishes, replicas stay private
services:
  proxy:
    image: traefik:v3
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
    ports: ["80:80"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
  api:
    image: api:prod
    labels:
      - traefik.enable=true
      - traefik.http.routers.api.rule=Host(`app.local`)
      - traefik.http.services.api.loadbalancer.server.port=8080

# docker compose up -d --scale api=3
Q46

Environment variables, mounted secret files, and Compose/Swarm secrets: how should runtime credentials reach a container, and what leaks where?

IntermediateSecurity

Answer

Environment variables are the default channel and the leakiest. Anything in -e or environment: is visible to docker inspect (Config.Env) for anyone with socket access, shows up in /proc/<pid>/environ, gets inherited by every child process, and has a habit of escaping via crash reporters, debug endpoints (/env style actuator pages), and error-tracking SDKs that helpfully capture environment context. They are acceptable for non-secret config (log level, feature URLs) and tolerated for secrets in smaller setups, but a hardening review will flag them.

Mounted files are one step better: the secret lives on a tmpfs or restricted-permission mount, the app reads it at boot, and it never appears in inspect output or process environments; rotation becomes 'update the file, signal the app'. This is exactly the contract of Swarm secrets: stored encrypted in the Raft log, delivered only to services granted them, appearing as in-memory files under /run/secrets/<name>, never written to disk on workers, never in inspect. Compose (without Swarm) borrows the syntax: a top-level secrets: block with file: sources mounts them at /run/secrets, giving you the file-shaped interface locally, albeit backed by plain host files rather than an encrypted store.

Kubernetes Secrets, Vault agent sidecars, and cloud secret managers (AWS Secrets Manager with ECS integration) continue the same file-or-API pattern at scale. The interview-grade synthesis: prefer file-shaped delivery because it survives inspect, supports rotation, and matches every orchestrator's native mechanism; make the application accept SECRET_FILE-style indirection (read path if set, fall back to env) so one image works across all environments; never bake secrets into images (that is a build-time question with a different answer, secret mounts); and remember .env files are for developer convenience, must be gitignored and dockerignored, and have been the root cause of enough public breaches to deserve explicit mention.

# Compose secrets: file-shaped, not in inspect's Config.Env
services:
  api:
    image: api:prod
    secrets: [db_password]
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password   # indirection
secrets:
  db_password:
    file: ./secrets/db_password.txt   # gitignored, dockerignored

# App pattern (node):
# const pw = process.env.DB_PASSWORD_FILE
#   ? fs.readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim()
#   : process.env.DB_PASSWORD;

# What leaks with plain env vars:
# docker inspect --format '{{json .Config.Env}}' api
# cat /proc/1/environ (inside the container)
Q47

docker save/load vs docker export/import: what does each pair preserve, and when do you use them for air-gapped deployments?

IntermediateImages & Registries

Answer

The pairs look interchangeable and are not, which is why this is a perennial screening question. docker save writes an image (or several) to a tar archive with everything that makes it an image: all layers, the config JSON (ENV, CMD, ENTRYPOINT, EXPOSE, labels), and tag references; docker load restores it bit-for-bit on another host. Round-tripping through save/load preserves layer structure and metadata completely. docker export operates on a container, not an image: it flattens the container's current merged filesystem (image layers plus whatever the writable layer holds) into a tar of plain files, discarding all history, all layering, and, critically, all config metadata; docker import turns such a tar back into a single-layer image with no CMD or ENTRYPOINT, so a docker run of an imported image fails with 'no command specified' unless you pass a command or set one with import's -c flag. That lost-ENTRYPOINT trap is the detail interviewers fish for.

Use cases: save/load is the tool for air-gapped and restricted environments, moving vetted images into networks with no registry access (defence, banking, and PSU-adjacent projects in India run exactly this workflow: build inside the connected zone, scan, save, carry across the diode, load); it also suits seeding a fleet of edge devices from local media. export/import is for flattening when you deliberately want history gone, forensic capture of a container filesystem, or migrating a container's state into a base tarball. Operational notes that add credibility: save preserves multiple tags if you list them, saving a multi-arch tag saves what your store holds (typically the current platform, so air-gap workflows should save per-platform explicitly or use OCI-layout tooling like skopeo copy, which is also the modern registry-to-registry mover); compress with gzip or zstd because layer tars are large; and for repeated air-gap syncs a private registry inside the gap plus periodic media transfers of new blobs beats hand-carrying tarballs per release.

# Image transfer with everything intact (air-gap flow)
docker save registry.example.com/api:1.4.2 | zstd > api-1.4.2.tar.zst
# ...carry media across...
zstd -d < api-1.4.2.tar.zst | docker load
docker run -d registry.example.com/api:1.4.2     # CMD/ENV all present

# Container flatten: metadata is GONE
docker export prod-api-1 > api-fs.tar
docker import api-fs.tar api:flat
docker run api:flat
# docker: Error ... no command specified
docker import -c 'CMD ["node","server.js"]' api-fs.tar api:flat2

# Modern registry-to-registry alternative (no daemon needed)
# skopeo copy docker://src.example.com/api:1.4.2 docker://gap.example.com/api:1.4.2
Q48

Docker Desktop vs Docker Engine: what is actually licensed, and where do Colima and Podman fit as alternatives in 2026?

IntermediateEcosystem

Answer

The distinction trips up teams and occasionally procurement. Docker Engine, the daemon, CLI, BuildKit, Compose plugin, is open source (Apache 2.0) and free for any use; every Linux server running docker via the apt/yum packages owes nothing. Docker Desktop is the proprietary macOS/Windows product bundling a managed Linux VM, the file-sharing and networking integration, the GUI dashboard, Kubernetes toggle, and extras like docker scout and docker debug; its subscription terms require a paid plan for larger companies (the threshold has been on the order of 250 employees or 10M USD revenue), while personal use, education, and small business remain free.

India-specific relevance: services companies and GCCs with thousands of seats did real migrations off Desktop when enforcement tightened, so 'what did your team use on developer laptops and why' is a genuine interview probe. The alternatives: Colima runs a Lima-managed Linux VM on macOS with Docker Engine (or containerd) inside and hands you an ordinary docker context; the CLI experience is nearly identical, no license, with trade-offs in file-sharing performance tuning and lacking Desktop's GUI and integrations. Podman takes a different architectural line: daemonless, each container is a child of the invoking process, rootless as the default posture, CLI-compatible enough that alias docker=podman covers most workflows, with pods as a first-class grouping and quadlet/systemd integration for services; podman machine provides the VM on Mac/Windows, and Podman Desktop the GUI.

Buildah and skopeo cover build and registry operations in that ecosystem. The honest comparison to give: for plain 'build images, run Compose stacks' workflows all three work; Desktop buys polish, VirtioFS file sharing, and supported integrations at a per-seat cost; Colima buys the same engine free with more self-support; Podman buys the strongest default security story with occasional compatibility friction (docker.sock-expecting tooling like some Testcontainers setups needs its socket-compat service). Docker-format images run identically on all of them because OCI images are the shared standard, which is the closing point that shows you understand images are decoupled from any vendor's runtime.

Key Points

  • Engine on Linux: open source, free everywhere, no Desktop involved
  • Desktop: proprietary Mac/Windows bundle; paid above company-size thresholds
  • Colima: free Lima VM + real Docker Engine, near-identical CLI workflow
  • Podman: daemonless, rootless-first, docker-aliasable; socket-compat for tooling
  • OCI images run unchanged across all of them
Q49

Explain how the overlay2 storage driver assembles a container filesystem: lowerdir, upperdir, whiteouts, and the performance edges of copy-on-write.

AdvancedInternals

Answer

overlay2 uses the kernel's OverlayFS. Every image layer is stored under /var/lib/docker/overlay2/<id> as a diff directory containing only that layer's changes. When a container starts, Docker constructs a union mount: lowerdir is the colon-joined stack of read-only image layer diffs, upperdir is the container's fresh writable directory, workdir is OverlayFS scratch space, and merged is the unified view the container sees as /.

Reads resolve top-down: the first layer (upper first, then lowers in order) containing a path wins. Writes trigger copy-up: the first modification of a file that lives in a lowerdir copies the entire file into upperdir before applying the change, so appending one byte to a 5 GB file inside a container first copies 5 GB, which is the canonical explanation for 'why is my in-container database slow', and why data-heavy paths belong on volumes, which bypass OverlayFS entirely. Deletes create whiteouts: removing a lowerdir file places a character device marker in upperdir that masks it, and an opaque directory xattr masks whole directories; nothing is ever removed from image layers, which is also why deleting secrets in a later Dockerfile layer does not redact them.

Other edges worth knowing: metadata operations on huge directories copied-up can spike latency; rename across the copy-up boundary is not atomic the way applications sometimes assume; OverlayFS historically diverged from POSIX in corners like fd behaviour across copy-up, which bit older MySQL and led to the volumes-for-data rule becoming dogma. Also mention the succession story: the graphdriver architecture (overlay2 among others) is being superseded by the containerd image store and its snapshotters, which unify how Docker and Kubernetes manage layers and unlock lazy-pulling formats (stargz/eStargz) where containers start before the full image downloads. Confirm what a host runs with docker info: 'Storage Driver: overlay2' or the containerd snapshotter marker.

Key Points

  • merged = upperdir (writable) over ordered lowerdirs (image layers)
  • First write copies the whole file up; big mutable files punish CoW
  • Whiteout markers mask deletions; lower layers are immutable forever
  • Volumes bypass OverlayFS; that is why databases must use them
  • containerd snapshotters are the successor to the graphdriver model
Q50

Docker, containerd, runc, and the OCI specs: who does what at container start, and what did Kubernetes removing dockershim actually change?

AdvancedInternals

Answer

The stack is layered by open standards. The OCI image-spec defines what an image is (manifests, config, layer tars); the runtime-spec defines a bundle (rootfs plus config.json) and the lifecycle a runtime must implement; the distribution-spec covers registries. runc is the reference OCI runtime: given a bundle, it performs the namespace/cgroup/seccomp/capability setup and execs the process, then exits, it is not resident. containerd is the daemon that manages the lifecycle: pulling images, managing snapshots (layer storage), creating containers, supervising them via a per-container shim process (containerd-shim-runc-v2) that holds the process's stdio and exit status so containerd itself can restart without killing workloads, that same shim design is what enables dockerd's live-restore option. dockerd sits on top adding the developer-facing surface: the Docker API, image building (delegating to BuildKit), networking (libnetwork: bridges, embedded DNS, port publishing), volumes, and Compose/Swarm integration. So docker run is: CLI -> dockerd -> containerd -> shim -> runc -> your process.

The dockershim story tests whether candidates understand this layering. Kubernetes talks to runtimes via CRI; dockerd never implemented CRI, so kubelet carried a translation layer (dockershim) that spoke Docker API and was removed in Kubernetes 1.24. Nodes now run containerd (or CRI-O) directly, skipping the dockerd layer.

What did not change: images. Docker-built images are OCI images; they run under containerd/CRI-O identically, and 'Kubernetes dropped Docker support' was a wire-format cleanup, not an image incompatibility, being able to say that crisply is the point of the question. What did change operationally: no docker CLI on nodes (use crictl/nerdctl for node debugging), and anything that depended on the node's docker.sock (old DinD builders, some log collectors) needed rework. Alternative runtimes slot in at the runc seam: gVisor (runsc) and Kata Containers implement the same OCI runtime contract with syscall-filtering or micro-VM isolation for untrusted workloads.

Key Points

  • OCI specs standardise image, runtime, and distribution layers
  • runc sets up and execs, then exits; shims supervise; containerd manages
  • dockerd adds API, build, networking, volumes on top of containerd
  • dockershim removal swapped node plumbing; OCI images unaffected
  • gVisor/Kata replace runc for stronger isolation, same interface
💡 Pro Tip: The one-line answer to 'did Kubernetes drop Docker?': it dropped the dockerd node dependency, not Docker-built images, which are OCI-standard and run under containerd unchanged.
Q51

Rootless Docker and user namespace remapping: how do they contain a container escape, and what stops working?

AdvancedSecurity

Answer

Both features attack the same risk: in stock Docker, UID 0 in a container is UID 0 on the host, and any runtime or kernel escape lands the attacker as host root. User namespaces break that identity by mapping container UIDs onto an unprivileged host range defined in /etc/subuid and /etc/subgid. With daemon-level remapping (userns-remap in daemon.json), dockerd still runs as root but every container's root becomes something like host UID 231072; an escapee owns nothing on the host.

Rootless mode goes further: the daemon itself runs as a normal user, using newuidmap/newgidmap for the mappings, so even a daemon compromise is contained to that user's privileges. The costs are where interview depth shows. Networking: rootless cannot create real veth/bridge devices, so it tunnels through user-space networking (slirp4netns historically, with newer stacks improving throughput); performance is lower than kernel bridging, and binding host ports below 1024 needs sysctl help (net.ipv4.ip_unprivileged_port_start).

Storage: overlayfs from a user namespace needs a reasonably modern kernel (older setups fell back to fuse-overlayfs with a performance tax); enabling userns-remap on an existing host effectively re-owns image and container storage, so existing local images 'disappear' into a different storage path and get re-pulled. Compatibility: --privileged and features needing real capabilities on host resources (some NFS mounts, certain device access, --network host semantics) do not work or change meaning; anything that assumed host-UID file sharing over bind mounts must adapt because host files owned by your user appear as owned by an unmapped overflow UID inside remapped containers unless mappings are arranged. Also name the ecosystem context: Podman made rootless the default posture years earlier, Kubernetes has user-namespace support for pods, and CI is a sweet spot for rootless builders (rootless BuildKit, kaniko) because build hosts run the most attacker-influenced code. The balanced conclusion: remapping/rootless is high-value on multi-tenant and build infrastructure, and worth the friction there, while single-tenant app hosts often stay on the default with hardening flags instead.

# Daemon-level remapping: /etc/docker/daemon.json
# { "userns-remap": "default" }
# creates 'dockremap' user; ranges from /etc/subuid, /etc/subgid

# Verify from inside a remapped container
docker run --rm alpine sh -c 'id -u; cat /proc/self/uid_map'
# 0
# 0     231072      65536      <- container 0 = host 231072

# Rootless install (as the target user)
curl -fsSL https://get.docker.com/rootless | sh
systemctl --user enable --now docker
export DOCKER_HOST=unix:///run/user/1000/docker.sock

# Low ports for rootless
sudo sysctl net.ipv4.ip_unprivileged_port_start=80
Q52

Docker Swarm in 2026: how do services, overlay networks, and rolling updates work, and when is choosing Swarm over Kubernetes still defensible?

AdvancedOrchestration

Answer

Swarm is Docker's built-in orchestrator: docker swarm init turns a host into a manager, docker swarm join adds workers, and managers maintain cluster state over Raft (so you run an odd number, typically three, and losing quorum freezes orchestration while existing containers keep running, a detail worth volunteering). The unit is a service: docker service create --replicas 3 declares desired state, and managers schedule tasks (containers) across nodes, replacing them on failure and rebalancing on node loss. Networking is the strongest part of the story: overlay networks span nodes via VXLAN, every service gets a DNS name and a VIP with built-in load balancing across replicas, and the routing mesh publishes a service's port on every node, so an external LB can hit any node and reach a replica anywhere.

Rolling updates are first-class: --update-parallelism, --update-delay, --update-order start-first for zero-downtime, --update-failure-action rollback, integrated with container healthchecks so a bad image stops rolling and reverts. Secrets and configs are encrypted in the Raft store and delivered as tmpfs files. Deployment reuses Compose syntax via docker stack deploy, where the deploy: key (replicas, placement constraints, resources, update_config) finally takes effect.

The honest 2026 positioning: Kubernetes won the ecosystem, managed control planes (EKS/GKE/AKS), operators, autoscaling (HPA/VPA/cluster-autoscaler on metrics), service meshes, and the hiring market, and Swarm's development pace and third-party tooling are thin by comparison. Choosing Swarm remains defensible for a specific profile: a small team, a handful of nodes, on-prem or single-cloud, workloads that fit 'N replicas behind a port with rolling updates', where you get orchestration for roughly the operational cost of Docker itself, no separate control plane, no YAML ecosystem to master. Plenty of Indian SMEs and internal platforms run exactly that happily. The disqualifiers to state: need for autoscaling, complex stateful operators, multi-team RBAC, or cloud-managed control planes all point to Kubernetes, and betting a growing platform on Swarm's ecosystem is a risk you should name rather than discover.

# Cluster bootstrap
docker swarm init --advertise-addr 10.0.0.10
docker swarm join-token worker      # run output on workers

# Service with zero-downtime rolling updates
docker service create --name api --replicas 3 \
  --publish 8080:8080 \
  --update-parallelism 1 --update-delay 10s \
  --update-order start-first \
  --update-failure-action rollback \
  registry.example.com/api:1.4.2

# Deploy a Compose file as a stack
docker stack deploy -c compose.prod.yaml app
docker service ps app_api           # task placement + update history

# Roll forward / back
docker service update --image registry.example.com/api:1.4.3 api
docker service rollback api
Q53

A JVM (or Node.js) service in a 512 MB container keeps getting OOMKilled even though 'the heap is only 300 MB'. Walk through the real memory accounting and the fix.

AdvancedProduction Failure Modes

Answer

The cgroup limit meters the whole container: every process, and every kind of memory, heap, thread stacks (each Java thread costs roughly a megabyte of stack), Metaspace, code cache, direct/NIO buffers, native allocations from JNI or libraries, plus page cache attributed to the cgroup. A '300 MB heap' JVM easily holds 450-500 MB resident, so a 512 MB limit leaves no headroom and the OOM killer fires, exit 137, OOMKilled: true, and nothing in the application log because SIGKILL is not observable. History explains the second trap: JVMs long sized defaults from host memory; container awareness (UseContainerSupport, on by default in all supported JDKs for years) reads cgroup limits, including cgroup v2 on modern JDKs, but only governs the heap fraction, default MaxRAMPercentage is conservative (25%), and none of it caps native memory.

The fix is a budget, not a flag: decide the container limit, then apportion, for a 1 GiB container something like -XX:MaxRAMPercentage=60 to 70 for heap, explicit -XX:MaxMetaspaceSize and -XX:MaxDirectMemorySize if those are known consumers, and leave 25-30% headroom for stacks and native; verify with jcmd VM.native_memory (NativeMemoryTracking=summary) rather than guessing. Node.js is the same story with different knobs: the V8 old-space default does not track the cgroup limit reliably across versions, so set --max-old-space-size (in MB) to roughly 75-80% of the container limit, remembering buffers and native addons live outside old space. Confirm diagnosis before fixing: docker inspect for OOMKilled, dmesg/journalctl on the host for the oom-kill line showing the cgroup and per-process RSS, and docker stats trended over time to distinguish a leak (monotonic growth) from an under-provisioned steady state. The closing pattern interviewers reward: limits and runtime flags must be set together, from one source of truth (deployment template computes both), because a limit changed without re-deriving heap flags reintroduces the bug six months later.

# Confirm it was the OOM killer
docker inspect --format 'OOM={{.State.OOMKilled}} Exit={{.State.ExitCode}}' api
# host: journalctl -k | grep -i 'oom-kill'   -> shows cgroup + RSS

# JVM: budget the whole container, not just heap
docker run -d --memory 1g --memory-swap 1g \
  -e JAVA_TOOL_OPTIONS='-XX:MaxRAMPercentage=65 -XX:MaxMetaspaceSize=128m -XX:NativeMemoryTracking=summary' \
  api-jvm:prod
# inside: jcmd 1 VM.native_memory summary

# Node: cap old space below the cgroup limit
docker run -d --memory 512m --memory-swap 512m \
  -e NODE_OPTIONS='--max-old-space-size=400' \
  api-node:prod
💡 Pro Tip: Say 'the limit meters the container, the heap flag meters one region of one process' early; the whole question is that mismatch.
Q54

Container A cannot reach container B, but ping to the internet works from both. Give a systematic Docker network debugging runbook, including DNS, iptables, and MTU failure classes.

AdvancedNetworking & Debugging

Answer

Debug in layers, cheapest checks first. Topology: docker inspect --format '{{json .NetworkSettings.Networks}}' on both containers; the most common cause is simply different networks (one started by Compose on project_default, the other by hand on the default bridge), fixed with docker network connect or correct compose membership. DNS next: exec getent hosts b in A (ping is often absent in slim images and ICMP proves little); name resolution on user-defined networks goes to the embedded server at 127.0.0.11, so if getent fails but the IP works, check /etc/resolv.conf inside the container, custom dns: settings, or a host firewall interfering with the DNS proxy; if the name resolves to a stale IP, the target was recreated outside Docker's awareness (rare, but --network host services registering themselves cause lookalikes).

Reachability vs service: nc -zv b-ip 5432 distinguishes 'network broken' from 'process not listening'; inside B, ss -lntp answers whether the service bound 0.0.0.0 or only 127.0.0.1, the second-most-common cause overall. If B's image has no tools, attach a debug container to B's namespaces: docker run --rm -it --network container:b nicolaka/netshoot gives tcpdump/ss/dig against B's exact network view without touching B. Packet level: tcpdump on the bridge interface or inside netshoot shows whether SYNs arrive and what answers; ICMP working while TCP fails points at filtering or MTU.

Filtering: iptables -L DOCKER-USER -n and inter-container policies (icc=false on the daemon, network-level internal: true, or a security tool programming drops); conntrack -L exhaustion (nf_conntrack: table full in dmesg) throws intermittent drops under load. MTU class: overlay/VXLAN and VPN environments (WireGuard, corporate tunnels) shrink effective MTU; symptoms are the signature 'small requests fine, large responses hang' (TLS handshakes stall, curl of big payloads freezes) because oversized packets with DF set vanish where ICMP frag-needed is blocked; verify with ping -M do -s 1472 sweeps and fix by setting the docker network's MTU option (com.docker.network.driver.mtu) to match the path. Close the runbook with docker events --since and daemon logs for churn (a container flapping mid-diagnosis explains ghosts), and the discipline of changing one variable at a time.

# 1. Same network? Real IPs?
docker inspect --format '{{json .NetworkSettings.Networks}}' a | jq
docker inspect --format '{{json .NetworkSettings.Networks}}' b | jq

# 2. DNS then TCP, from inside A
docker exec a getent hosts b
docker exec a nc -zv b 5432

# 3. Is B even listening on the right interface?
docker exec b ss -lntp        # 127.0.0.1:5432 vs 0.0.0.0:5432

# 4. Toolless image? Borrow B's namespaces
docker run --rm -it --network container:b nicolaka/netshoot
# inside: tcpdump -ni eth0 port 5432

# 5. Filtering and MTU classes
sudo iptables -L DOCKER-USER -n -v
docker exec a ping -M do -s 1472 -c1 b   # DF-bit MTU sweep
# fix: docker network create -o com.docker.network.driver.mtu=1400 appnet
Q55

Design a zero-downtime deployment on a single Docker host without Kubernetes: what sequencing, health gating, and proxy behaviour make it actually zero-downtime?

AdvancedDeployment

Answer

The naive flow, docker stop old && docker run new, has an outage exactly as long as the new app's boot time, and Compose's default recreate does the same. Zero-downtime on one host requires overlap: the new version must be started, verified healthy, and receiving traffic before the old one stops. The blue-green shape: run app-blue behind a reverse proxy (nginx or Traefik); deploy app-green from the new image on the same Docker network; gate on real health, poll the container's healthcheck status via docker inspect until Health.Status is healthy, or curl its readiness endpoint through the network, with a bounded timeout that aborts the deploy on failure; then switch the proxy, with Traefik this is automatic via labels and health-aware service discovery, with nginx you rewrite the upstream and issue a reload (SIGHUP), which is graceful by design, old workers finish in-flight requests on old connections while new ones use the new upstream; finally drain and stop blue with a stop timeout that exceeds your longest acceptable request, relying on the app's SIGTERM handler to stop accepting and finish in-flight work (exit 143).

Three correctness details separate senior answers: connection draining, the proxy must stop sending new requests before the old container receives SIGTERM, hence proxy switch first, stop later, plus keep-alive connections need the app to close them on shutdown or the proxy to retire them; overlap-safety, running two versions concurrently constrains database migrations to be backward-compatible (expand-and-contract), and if the app cannot tolerate two live versions you do not have zero-downtime deploys, you have a migration-strategy problem; and rollback, keeping blue stopped-but-present for a fast docker start rollback, with the previous image tag recorded. Swarm packages this whole dance as docker service update --update-order start-first with healthcheck integration, which is a fair 'buy vs build' note to end on, and on multi-host platforms the same logic reappears as rolling updates with readiness gates, so articulating it from first principles proves you understand what those orchestrators automate.

#!/usr/bin/env bash
set -euo pipefail
NEW_TAG=$1

docker run -d --name app-green --network prod \
  --restart unless-stopped registry.example.com/api:$NEW_TAG

# Gate on the container's own healthcheck
for i in $(seq 1 30); do
  s=$(docker inspect --format '{{.State.Health.Status}}' app-green)
  [ "$s" = healthy ] && break
  [ "$i" = 30 ] && { docker rm -f app-green; echo 'deploy aborted'; exit 1; }
  sleep 2
done

# Repoint nginx upstream and reload gracefully (drains old conns)
sed -i 's/app-blue/app-green/' /etc/nginx/conf.d/upstream.conf
docker exec proxy nginx -s reload

# Drain, then stop the old container with a generous timeout
sleep 10
docker stop -t 60 app-blue && docker rename app-blue app-prev
Q56

How do BuildKit provenance and SBOM attestations, digest pinning, and cosign signing fit together into a container supply-chain story?

AdvancedSupply Chain Security

Answer

The threat model has three legs: you build something other than what you think (compromised dependency or base image), someone tampers with the artifact between build and deploy, or you cannot answer 'which images contain package X' when a disclosure lands. The controls map onto them. Reproducible inputs: pin base images by digest (FROM node:22-slim@sha256:...) so a registry-side tag repoint cannot change your build silently, and let Renovate-style bots bump digests through review; the same logic extends to pinning package versions via lockfiles installed with npm ci/pip install --require-hashes.

Build-time evidence: buildx generates attestations, --provenance=true attaches a SLSA provenance document (who built it, from which git commit, with which parameters, on which builder) and --sbom=true attaches an SBOM enumerating packages, both stored in the registry alongside the image and inspectable with docker buildx imagetools inspect; provenance answers 'where did this come from', the SBOM answers the disclosure-day query without rescanning. Integrity and authorship: cosign (Sigstore) signs the image digest, keyless signing binds the signature to a CI identity (an OIDC token from the Actions/GitLab job) rather than a long-lived private key, and signatures live in the registry next to the image. Enforcement is what makes any of it matter: verification at deploy time, cosign verify in the pipeline, or a Kubernetes admission policy (Kyverno or the policy-controller) that rejects unsigned or unattested images, turns the artifacts from paperwork into a gate.

Docker's older Content Trust/Notary v1 (DOCKER_CONTENT_TRUST) is the legacy answer here and effectively superseded by the Sigstore ecosystem, worth saying so an interviewer knows you are current. A credible closing summary: build once in CI, emit provenance and SBOM, sign the digest keylessly, deploy by digest with verification enforced, and store nothing but the registry as the source of truth, then the 'could a tampered image reach production' question has a checkable no.

# Build with provenance + SBOM attached to the pushed image
docker buildx build \
  --provenance=true --sbom=true \
  -t registry.example.com/api:git-$GIT_SHA --push .

# Inspect attestations
docker buildx imagetools inspect \
  registry.example.com/api:git-$GIT_SHA --format '{{json .SBOM}}'

# Keyless sign in CI (identity = the CI job's OIDC token)
cosign sign --yes registry.example.com/api@sha256:$DIGEST

# Enforce before deploy
cosign verify \
  --certificate-identity-regexp 'github.com/goodspace/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  registry.example.com/api@sha256:$DIGEST
Q57

What actually lives in a registry: manifests, image indexes, config and layer blobs. Trace a docker pull at the HTTP level and explain how digest addressing makes it tamper-evident.

AdvancedInternals

Answer

A registry stores exactly two kinds of objects: manifests (small JSON documents) and blobs (opaque binary, addressed by sha256 of their content), under the OCI distribution API. For a single-platform image, the manifest lists the config blob (the image's JSON config: ENV, CMD, layer ordering, history) and each layer blob (compressed tar diffs) with their digests, sizes, and media types (application/vnd.oci.image.layer.v1.tar+gzip and friends; the older Docker media types are interchangeable in practice, and both docker and OCI formats coexist in most registries). For multi-arch, the tag resolves first to an image index (manifest list), a manifest of manifests keyed by platform.

The pull, at HTTP level: GET /v2/<name>/manifests/<tag> with Accept headers naming the manifest media types; the response carries the manifest and its digest in the Docker-Content-Digest header; if it is an index, the client picks its platform's entry and fetches that manifest by digest; then for the config and each layer it checks local content-addressed storage and issues GET /v2/<name>/blobs/<sha256:...> only for what is missing, in parallel, verifying that each downloaded blob hashes to its advertised digest. That verification chain is the tamper-evidence: the layer digests are inside the manifest, the manifest's own digest is what pull-by-digest references pin, so image@sha256:X transitively fixes every byte; a registry (or man-in-the-middle) serving modified content fails hash verification client-side. Tags are the one mutable pointer in the system, which is precisely why deploy-by-digest is the security recommendation. Useful implications that show mastery: cross-repository blob mounting lets a push skip uploading blobs the registry already holds elsewhere (fast promotes between repos); docker manifest inspect / buildx imagetools inspect are the CLI windows into all of this; registry garbage collection deletes unreferenced blobs only, so deleting a tag frees nothing until GC runs with manifests gone; and the same manifest+blob machinery now carries non-image OCI artifacts, Helm charts, SBOMs, cosign signatures, and attestations all ride the registry as blobs referenced by manifests, which is why one registry can be the single artifact store for a platform team.

# Resolve a tag to its manifest (index for multi-arch)
docker buildx imagetools inspect nginx:1.27 --raw | jq '.mediaType, .manifests[].platform'

# Raw HTTP shape of a pull
# GET /v2/library/nginx/manifests/1.27
#   Accept: application/vnd.oci.image.index.v1+json, ...
#   -> Docker-Content-Digest: sha256:<manifest-digest>
# GET /v2/library/nginx/blobs/sha256:<config-digest>
# GET /v2/library/nginx/blobs/sha256:<layer-digest>   (parallel, verified)

# Pin transitively: this reference can never change content
docker pull nginx@sha256:6b06964cdbbc517102ce5e0cef95152f3c6a7ef703e4057cb574539de91f72e6

# Layer digests referenced by the local config
docker inspect --format '{{json .RootFS.Layers}}' nginx:1.27 | jq
Q58

How do you run GPU workloads in Docker with the NVIDIA Container Toolkit, and what image-design choices keep multi-GB CUDA images manageable?

AdvancedGPU & ML Workloads

Answer

Containers cannot see GPUs by default; the NVIDIA Container Toolkit bridges the gap. The host keeps the kernel driver; the toolkit registers a runtime hook so that docker run --gpus all (or --gpus '"device=0,1"' for specific cards) injects the device nodes and mounts the host's driver user-space libraries into the container. The division of responsibility is the part interviewers probe: driver on the host, CUDA toolkit in the image, and the image's CUDA version must be compatible with the host driver (drivers support CUDA versions up to their maximum; the NVIDIA_REQUIRE_CUDA constraints in official images encode this), the root cause behind 'CUDA driver version is insufficient' errors after someone upgrades an image but not a fleet's drivers.

Verify plumbing with docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi before debugging anything at the framework level. Image design is a size war: nvidia/cuda comes in base (runtime API only), runtime (CUDA libraries), and devel (compilers, headers) flavours, and PyTorch images with full CUDA easily cross 5-8 GB. Tactics that work: multi-stage so devel images compile custom kernels/extensions but runtime images ship; choosing framework wheels that bundle their own CUDA libs (letting you start from a slimmer base rather than full CUDA images); aggressive layer discipline so the model weights are not baked into the image at all, weights belong in volumes, object storage, or model registries, pulled at startup or mounted, because a 10 GB image per model version destroys pull times, registry storage, and autoscaling latency; and pinning exact framework+CUDA tags because 'latest' in ML images is chaos. Operational notes that round it out: --gpus integrates with Compose via device_reservations (driver: nvidia, count/device_ids, capabilities: [gpu]); on Kubernetes the same role is played by the NVIDIA device plugin and node selectors; GPU memory is not cgroup-limited, one container can starve another on the same card, so isolation strategies are whole-GPU assignment, MIG partitioning on datacentre cards, or time-slicing with its noisy-neighbour trade-offs; and shm matters, PyTorch DataLoader workers need --shm-size bumped (default 64 MB) or you get cryptic bus errors, a genuinely common production stumble worth naming unprompted.

# Host once: nvidia-container-toolkit installed, then verify
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi

# Serve a model: runtime base, weights mounted, shm raised
docker run -d --name llm \
  --gpus '"device=0"' \
  --shm-size 8g \
  -v /models/llama-8b:/models:ro \
  -p 8000:8000 \
  vllm/vllm-openai:latest --model /models

# Compose GPU reservation
# services:
#   trainer:
#     image: pytorch/pytorch:2.4.0-cuda12.4-cudnn9-runtime
#     shm_size: 8gb
#     deploy:
#       resources:
#         reservations:
#           devices:
#             - driver: nvidia
#               count: 1
#               capabilities: [gpu]
Q59

What has materially changed in the Docker toolchain over the last few years that an interviewer would expect you to know: BuildKit by default, Compose v2, the containerd image store, docker init and scout?

AdvancedEcosystem Evolution

Answer

Interviewers use this to date your experience, because tutorials from the docker-compose v1 era still dominate search results. The load-bearing changes: BuildKit became the default builder (Docker Engine 23.0 on Linux; Desktop earlier), so cache mounts, secret mounts, heredocs, parallel multi-stage execution, and the --mount family are simply how builds work now, and answers that treat them as exotic opt-ins read as dated; DOCKER_BUILDKIT=1 as a required incantation is a v20-era tell. Compose v1 (the Python docker-compose binary) reached end of life and was removed; Compose v2 is a Go CLI plugin invoked as docker compose, the YAML version: key is obsolete under the unified Compose Specification, and genuinely new capabilities arrived over time, profiles, healthcheck-gated depends_on conditions, and docker compose watch for sync/rebuild dev loops.

The image store is migrating from the legacy graphdrivers to containerd snapshotters (default for new Docker Desktop installs in recent releases, opt-in on Engine via daemon.json), which is what makes multi-platform images, attestations, and lazy-pull formats first-class in the local store rather than registry-only concepts, and explains why docker buildx --load historically fought multi-arch but stops being a limitation under containerd. Supply-chain surface became built-in: buildx --provenance/--sbom attestations and docker scout for CVE analysis and base-image recommendations. Convenience tooling: docker init scaffolds sensible Dockerfiles/compose files per language, docker debug (Desktop, paid tiers) attaches a toolbox shell to shell-less containers.

And the org-chart fact behind several of these: Docker donated containerd and BuildKit-adjacent pieces to CNCF years ago, the OCI owns the specs, so 'Docker' in 2026 names a company, a CLI/daemon, and a set of standards that mostly outgrew it, being precise about which one you mean is itself a senior signal. If asked for versions, Engine's cadence has been steady major releases (the 23 through 28 line over 2023-2025); safest phrasing in an interview is 'current stable Engine 28.x line' with the caveat that Desktop versions independently.

Key Points

  • BuildKit default since Engine 23.0: mounts, heredocs, parallel stages assumed
  • docker-compose v1 removed; docker compose v2, no version: key, watch/profiles
  • containerd image store replacing graphdrivers; multi-arch local store works
  • Attestations (--provenance/--sbom) and docker scout are built-in surface
  • containerd/BuildKit under CNCF; OCI owns the specs Docker implements
Q60

You own a production Docker host (no Kubernetes). What goes into /etc/docker/daemon.json, and what does live-restore change about upgrades?

AdvancedProduction Operations

Answer

daemon.json is the engine's declarative config, and a production host's file encodes most of single-host operational maturity. Logging first, because unrotated json-file logs are the most common self-inflicted outage: set log-driver to local (compressed, rotated sensibly) or json-file with explicit max-size/max-file, remembering it applies only to containers created afterwards. live-restore: true is the flag with the biggest operational meaning: normally, stopping dockerd kills every container (they are its children via containerd shims, and a daemon stop tears them down); with live-restore, containers keep running while the daemon is down and re-attach when it returns, converting engine upgrades and daemon crashes from full outages into control-plane-only blips, you cannot start/stop containers during the window, but traffic keeps flowing. Its limits are interview bait: it covers daemon restarts, not host reboots, and does not span major engine upgrades with incompatible on-disk state changes, so it narrows maintenance windows rather than eliminating them, and it does not work with Swarm-managed tasks.

Address pools: default-address-pools prevents Docker's self-assigned 172.17-ish subnets from colliding with corporate VPNs and peered VPCs, a classic 'the office VPN broke for everyone running Docker' incident; set pools from ranges your network team blessed. Security posture: no-new-privileges: true as a daemon default, userns-remap where workloads tolerate it, and explicitly never exposing hosts on tcp:// without TLS. Housekeeping and limits: default-ulimits (nofile for connection-heavy services), storage-driver pinned explicitly (or the containerd snapshotter feature flag where adopted), registry-mirrors pointing at a pull-through cache to dodge Hub rate limits, and metrics-addr exposing the engine's Prometheus endpoint so daemon health is graphed, not assumed.

Around the config file sit the cron-shaped practices: time-bounded docker system prune (never volumes), disk alerting on /var/lib/docker, and the discipline that every container carries restart policies, resource limits, and healthchecks, because on a single host the engine is your entire control plane and each omission is an unmonitored failure mode. Rehearse the upgrade: apply daemon.json changes with a config reload where supported (SIGHUP covers a subset of keys), full restart otherwise, and verify live-restore behaviour on a staging host before trusting it during a production engine bump.

// /etc/docker/daemon.json for a production single host
{
  "log-driver": "local",
  "log-opts": { "max-size": "10m", "max-file": "3" },
  "live-restore": true,
  "default-address-pools": [
    { "base": "10.200.0.0/16", "size": 24 }
  ],
  "no-new-privileges": true,
  "default-ulimits": {
    "nofile": { "Name": "nofile", "Soft": 65536, "Hard": 65536 }
  },
  "registry-mirrors": ["https://mirror.internal.example.com"],
  "metrics-addr": "127.0.0.1:9323"
}
// sudo systemctl restart docker  (containers survive: live-restore)
// verify: docker info | grep -i 'live restore'
💡 Pro Tip: live-restore is the single most quotable flag in this answer: 'daemon upgrades stop being container outages'. Pair it with its limits (reboots, major upgrades, Swarm) to avoid overselling.

Companies Hiring Docker

Flipkart
Razorpay
Zerodha
Swiggy
Freshworks
Atlassian
TCS
Infosys

Salary Insights

Average in India
₹7-24 LPA

Frequently Asked Questions

What salary can Docker skills command in India in 2026?

Docker alone is table stakes rather than a differentiator, but the roles that require it pay well. DevOps and platform engineers with solid Docker plus CI/CD land roughly in the ₹7-24 LPA band that GoodSpace tracks for this skill: ₹7-12 LPA for 1-3 years of experience, ₹12-18 LPA at mid-level, and ₹18-24 LPA or higher for seniors who pair containers with Kubernetes, Terraform, and cloud architecture. Product companies (Flipkart, Razorpay, Zerodha, Swiggy, Freshworks) and GCCs pay toward the top; services majors like TCS and Infosys start lower but hire in volume for cloud-migration work. The premium is never for Docker in isolation: it is for the ability to debug production containers, cut build times, and harden images, which is exactly what interview rounds test.

How long does it take to prepare for Docker interview rounds?

If you already ship code, two to three weeks of focused effort covers a typical DevOps screen: one week on fundamentals by actually containerising one of your own projects (multi-stage Dockerfile, Compose stack with a database, healthchecks), one week on the failure modes interviewers love (exit code 137, signal handling, cache invalidation, bind-mount permissions), and a few days rehearsing explanations aloud. Candidates who only watch tutorials plateau fast; the questions that decide offers ('your container is OOMKilled, walk me through it') can only be answered convincingly if you have broken and fixed things yourself. Deliberately cause the failures on your laptop: cap memory until the OOM killer fires, ship a shell-form CMD and watch docker stop hang, fill a disk with unrotated logs. Each takes minutes and produces a genuine war story.

What do interviewers expect from freshers vs experienced engineers on Docker?

Freshers are expected to explain images vs containers, write a clean Dockerfile for a language they know, use Compose for an app-plus-database setup, and reason about layers and caching; a personal project with a well-structured Dockerfile in the repo carries real weight. Nobody expects a fresher to have run production workloads. At 3+ years, the bar shifts to operations: debugging exit codes and restart loops, image size and build-time optimisation with BuildKit, security hardening (non-root, cap-drop, secret handling), CI pipeline design with layer caching, and the storage/networking internals behind the commands. Senior and platform roles add supply-chain controls (SBOMs, signing), rootless builds, and the judgment questions: when Compose is enough, when Swarm is defensible, when you need Kubernetes.

Is Docker still worth learning deeply in 2026, or should I jump straight to Kubernetes?

Learn Docker properly first; it is the layer Kubernetes stands on. Every pod runs OCI containers, every Deployment references images you must build well, and half of real Kubernetes debugging (CrashLoopBackOff, OOMKilled, image pull errors, probe failures) is container fundamentals wearing YAML clothing. Engineers who skipped to Kubernetes routinely stall on exactly those tickets. Docker the company matters less than it did, Kubernetes itself talks to containerd, but Docker the toolchain (Dockerfiles, BuildKit, Compose) remains the standard developer workflow, and Compose is still the default local dev environment at most companies. The efficient path: get genuinely good at images, networking, storage, and debugging with Docker, then layer Kubernetes on top, where you will find most concepts are the same ideas at cluster scale.

How does Docker knowledge compare with Podman, and does the difference matter in interviews?

The concepts transfer almost entirely: OCI images, registries, Dockerfiles (Containerfiles), volumes, and networks work the same way, and Podman deliberately mirrors the Docker CLI. The differences worth knowing for interviews: Podman is daemonless (containers are child processes, no central root daemon), rootless by default, and integrates with systemd via quadlets instead of restart policies. Enterprises on Red Hat platforms (common in Indian banking and telecom accounts) lean Podman; the startup and product ecosystem remains overwhelmingly Docker plus Kubernetes. Unless the JD names Podman or OpenShift, prepare Docker-first and spend an afternoon mapping the differences: being able to say 'the images are identical, the runtime architecture differs, here is what daemonless changes about security' converts the question into an easy win either way.

Which adjacent skills multiply the value of Docker on a resume?

In rough order of leverage: Kubernetes, because container skills without orchestration cap out quickly in 2026 hiring; one major cloud (AWS dominates Indian job listings, and ECS/EKS/ECR keep Docker knowledge directly relevant); CI/CD design in GitHub Actions or GitLab CI, where image build, cache, scan, and push pipelines are the daily work; Terraform for the infrastructure around the containers; and Linux fundamentals, since every hard Docker debug (namespaces, cgroups, iptables, OverlayFS) is really a Linux question. Observability (Prometheus, Grafana, OpenTelemetry) rounds out a platform profile. A candidate who can containerise an app, wire the CI pipeline, deploy it to a managed Kubernetes cluster, and explain the production failure modes at each layer matches the strongest current demand in the Indian market.

Introduction

Docker interviews in 2026 look nothing like they did five years ago. Nobody is impressed that you can run docker run hello-world; the daemon, BuildKit, and Compose v2 are assumed knowledge the way git is. What actually gets probed is whether you understand what a container is at the kernel level (namespaces and cgroups, not magic), why your image is 1.8 GB when it should be 80 MB, what exit code 137 means at 2 AM, and how a multi-stage build with cache mounts cuts a 12-minute CI pipeline down to 90 seconds. Docker is the substrate under Kubernetes, ECS, and every CI system, so weak fundamentals here surface in every other infrastructure round.

In India, Docker fluency is a baseline expectation for DevOps, SRE, platform, and increasingly plain backend roles. Flipkart, Swiggy, Razorpay, and Zerodha run containerised microservices at serious scale, and even the services majors (TCS, Infosys, Wipro) now screen for Dockerfile authoring and Compose in cloud-migration projects. Interviewers separate candidates who have only followed tutorials from those who have debugged production: they ask about OOMKilled loops, UID mismatches on bind mounts, Docker Hub pull rate limits breaking CI, multi-arch builds for Graviton and M-series Macs, and why mounting docker.sock into a container is effectively handing out root.

This guide contains 60 questions ordered basic to intermediate to advanced, each answered the way a strong candidate would in a real interview: concrete commands, exact flags, real failure modes, and the version-specific behaviour that changed with BuildKit becoming the default builder and Compose moving to the docker compose plugin. Work through the basic set to make sure there are no gaps in your mental model of images, layers, networking, and volumes, then use the intermediate and advanced sections to prepare for the production scenarios (security hardening, supply-chain attestations, storage-driver internals, zero-downtime rollouts) that decide offers at product companies paying at the top of the ₹7-24 LPA band.

Ready to practice Docker interviews?

Don't just read, practice these Docker questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview