Docker Interview Questions: Deep-Dive Answers for DevOps Roles
Docker changed the way the industry ships software. Before containers, deploying an app meant wrestling with OS differences, missing libraries, and environment drift between dev and prod. Teams lost days — sometimes weeks — chasing bugs that only appeared in production. Docker didn't just fix that problem; it restructured how engineers think about deployment entirely. That's why it now appears in almost every DevOps job description, and why interviewers probe it hard.
The real value Docker provides isn't just 'running things in containers.' It's reproducibility, isolation, and portability packaged together. A container is a lightweight, self-sufficient unit that carries its dependencies with it. Two containers on the same host are completely unaware of each other's libraries. That isolation means you can run Python 2 and Python 3 apps side-by-side without conflict — something that used to require separate VMs, each burning gigabytes of RAM.
By the end of this article you'll be able to answer the 12 most common Docker interview questions with confidence — not just reciting definitions, but explaining the reasoning behind design choices, calling out real trade-offs, and demonstrating the kind of hands-on instinct interviewers are actually looking for.
Core Concepts: Images, Containers, and the Daemon — What Interviewers Really Want to Hear
Most candidates can define an image and a container. What separates a strong answer is explaining the relationship between them.
An image is an immutable, layered snapshot of a filesystem and its metadata — think of it as a read-only template. A container is a running instance of that image, plus a thin writable layer on top. When the container dies, that writable layer is gone. This is why containers are considered ephemeral by design.
The Docker daemon (dockerd) is the long-running background process that does the actual work: building images, managing container lifecycles, handling networking, and talking to registries. The Docker CLI you type commands into is just a client that sends API requests to the daemon over a Unix socket.
Interviewers love asking about layers because they reveal whether you understand caching. Every instruction in a Dockerfile creates a new layer. Layers are cached by their content hash. If layer 3 changes, every layer after it is invalidated and must be rebuilt. This is why instruction order in a Dockerfile matters enormously for build speed — put the things that change least (installing OS packages) at the top, and the things that change most (copying your app source code) near the bottom.
# ─── STAGE 1: Base OS + system packages (rarely changes — cached aggressively) ─── FROM node:20-alpine AS base # Set working directory inside the container WORKDIR /app # ─── STAGE 2: Install dependencies (changes only when package.json changes) ─── # Copy ONLY the dependency manifests first — not the full source # If package.json hasn't changed, Docker reuses the cached npm install layer COPY package.json package-lock.json ./ RUN npm ci --omit=dev # ─── STAGE 3: Copy source code (changes on every commit — placed last) ─── # Because this layer is last, a source change only invalidates one layer COPY src/ ./src/ # Expose the port the app listens on (documentation only — doesn't publish it) EXPOSE 3000 # CMD is the default process. Use array syntax (exec form) — NOT shell form. # Shell form wraps the process in /bin/sh -c, which means signals (SIGTERM) # from 'docker stop' won't reach your app, causing a 10-second forced kill. CMD ["node", "src/server.js"]
[+] Building 14.2s (8/8) FINISHED
=> [base 1/1] FROM node:20-alpine 0.3s (cached)
=> [2/4] WORKDIR /app 0.0s (cached)
=> [3/4] COPY package.json package-lock.json ./ 0.1s
=> [4/4] RUN npm ci --omit=dev 11.8s
=> [5/5] COPY src/ ./src/ 0.1s
# Second build after changing only a source file:
$ docker build -t myapp:1.1 .
[+] Building 0.4s (8/8) FINISHED
=> [base 1/1] FROM node:20-alpine 0.0s (cached)
=> [3/4] COPY package.json ... 0.0s (cached)
=> [4/4] RUN npm ci 0.0s (CACHED) ← npm install skipped!
=> [5/5] COPY src/ ./src/ 0.1s ← only this layer rebuilt
Volumes vs Bind Mounts vs tmpfs — The Storage Question That Trips People Up
Data persistence is one of Docker's most misunderstood areas, and interviewers use it to separate people who've read the docs from people who've debugged production.
Containers are ephemeral. The writable layer that gets created when a container starts is destroyed when the container is removed. If you write a database file into that layer, you lose it the moment the container exits. The three storage mechanisms Docker offers each solve this differently.
A named volume is managed entirely by Docker. Docker decides where on the host filesystem the data lives (usually /var/lib/docker/volumes/). Your container just sees a directory. Volumes survive container deletion, can be shared between containers, and work across platforms. Use volumes for anything you care about keeping — databases, uploads, generated certificates.
A bind mount maps a specific host path into the container. You control the path. This is powerful for local development — you mount your source code directory into the container and edits you make on the host appear instantly inside the container, enabling hot-reload workflows. But bind mounts are tightly coupled to host filesystem layout, which makes them fragile in production and on teams with different OS conventions.
tmpfs mounts are stored in the host's memory only. The moment the container stops, the data is gone. Use tmpfs for sensitive temporary data you explicitly do not want written to disk — think secrets, session tokens, or scratch space for cryptographic operations.
#!/bin/bash # ─── PATTERN 1: Named Volume (Production databases) ─────────────────────────── # Docker manages the volume location — portable, survives container removal docker volume create postgres_data docker run -d \ --name postgres_prod \ -e POSTGRES_PASSWORD=secret \ -v postgres_data:/var/lib/postgresql/data \ # named volume → persistent postgres:16-alpine # Volume persists even after the container is removed docker rm -f postgres_prod docker volume ls # postgres_data is still here # ─── PATTERN 2: Bind Mount (Local development hot-reload) ───────────────────── # Mount your local source code into the container so edits take effect immediately # $(pwd) expands to your current directory on the host docker run -d \ --name api_dev \ -v $(pwd)/src:/app/src \ # host path → container path (tightly coupled to host) -p 3000:3000 \ myapp:dev # Editing ./src/server.js on your host instantly reflects inside the container # ─── PATTERN 3: tmpfs Mount (Sensitive ephemeral data) ──────────────────────── # Data lives only in RAM — never touches disk — wiped when container stops docker run -d \ --name token_processor \ --tmpfs /run/secrets:rw,noexec,nosuid,size=64m \ # 64MB RAM-backed mount myapp:prod # If someone gets a disk image of your host, the secrets are never there
DRIVER VOLUME NAME
local postgres_data
$ docker inspect postgres_data | grep Mountpoint
"Mountpoint": "/var/lib/docker/volumes/postgres_data/_data"
# After docker rm -f postgres_prod:
$ docker volume ls
DRIVER VOLUME NAME
local postgres_data ← still here, data is safe
Docker Networking Deep Dive — How Containers Actually Talk to Each Other
Networking is where many Docker users hit a wall. The mental model that unlocks it: each Docker network is a private virtual switch. Containers attached to the same switch can talk to each other by container name. Containers on different switches can't reach each other unless you explicitly connect them or use a shared network.
Docker ships with three built-in network drivers. The bridge driver (default) creates a private network on the host. Containers on the same bridge network can communicate with each other using DNS — Docker has a built-in DNS server that resolves container names and service names automatically. This is how a Node.js API container can connect to a Postgres container using the hostname 'postgres' rather than an IP address that changes every restart.
The host driver removes the network namespace entirely. The container uses the host's network stack directly. This gives maximum network performance (no virtual switch overhead) but destroys isolation — the container can see all host ports. Only use this for latency-critical workloads where the overhead of bridge networking measurably matters.
The none driver disables networking completely. The container has only a loopback interface. Useful for running batch jobs that must be air-gapped, or for testing how your app behaves with no network access.
In Docker Compose, every service gets added to a default network named after your project. Services reference each other by service name — which is why your app config can say db_host: postgres and it just works.
version: '3.9' services: # ─── API Service ─────────────────────────────────────────────────────────── api: build: ./api ports: - "3000:3000" # Publishes container port 3000 to host port 3000 environment: # Uses the service name 'database' as the hostname — Docker DNS resolves it DATABASE_URL: postgres://appuser:secret@database:5432/myapp REDIS_URL: redis://cache:6379 networks: - backend_net # Connected to the private backend network depends_on: database: condition: service_healthy # Won't start until postgres passes health check # ─── Database Service ─────────────────────────────────────────────────────── database: image: postgres:16-alpine environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: secret POSTGRES_DB: myapp volumes: - postgres_data:/var/lib/postgresql/data networks: - backend_net # Same network as api — they can talk by service name # No 'ports' key here — database is NOT exposed to the host, only to backend_net healthcheck: test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"] interval: 5s timeout: 3s retries: 5 # ─── Cache Service ────────────────────────────────────────────────────────── cache: image: redis:7-alpine networks: - backend_net # Same private network # Redis also has no published ports — can't be reached from outside the host networks: backend_net: driver: bridge # Default — creates isolated virtual network volumes: postgres_data:
[+] Running 4/4
✔ Network myapp_backend_net Created
✔ Container myapp-cache-1 Started
✔ Container myapp-database-1 Started (health: starting)
✔ Container myapp-api-1 Started (waited for healthy database)
# From inside the api container, 'database' resolves to the postgres container IP:
$ docker exec myapp-api-1 nslookup database
Server: 127.0.0.11 ← Docker's built-in DNS resolver
Address: 127.0.0.11:53
Name: database
Address: 172.18.0.3 ← private IP of the database container
# The database port is NOT reachable from the host:
$ psql -h localhost -p 5432 -U appuser myapp
psql: error: connection refused ← correct — it's only on backend_net
Multi-Stage Builds and Image Size — The Optimisation Question That Defines Seniors
A production Docker image should contain only what is needed to run the application. Not the compiler. Not the test framework. Not the build tools. Most candidates understand single-stage builds. Seniors reach for multi-stage builds by default.
The idea is simple: use one stage to build your app (with all the heavyweight tools that requires), then start fresh from a minimal base image and copy only the compiled output. The final image has no knowledge of how it was built — just what needs to run.
For a Go application this is dramatic: the builder stage might pull in the entire Go toolchain (hundreds of MB), but the final stage starts from scratch (literally 'FROM scratch') and contains only the statically compiled binary — often under 10MB total image size.
For JVM languages you typically go from a full JDK for compilation to a slim JRE for running. For Node.js you go from a full node image with devDependencies to a production install with only runtime dependencies.
Smaller images mean faster pulls from the registry, smaller attack surface (fewer binaries means fewer CVEs), faster CI pipelines, and lower egress costs when images are distributed across regions. These are the kinds of concrete trade-offs that turn an answer from textbook into impressive.
# ════════════════════════════════════════════════════════════════════════ # STAGE 1 — Builder # Uses the full Go toolchain to compile the binary # This stage is DISCARDED from the final image — it never ships to production # ════════════════════════════════════════════════════════════════════════ FROM golang:1.22-alpine AS builder # Install git only if your go modules reference private repos via git RUN apk add --no-cache git WORKDIR /build # Download dependencies first (cached separately from source changes) COPY go.mod go.sum ./ RUN go mod download # Copy source and build COPY . . # CGO_ENABLED=0 produces a statically linked binary with no libc dependency # GOOS=linux ensures the binary targets Linux even if you build on macOS # -ldflags trims debug info and embedded module paths to reduce binary size RUN CGO_ENABLED=0 GOOS=linux go build \ -ldflags="-w -s" \ -o /build/api_server \ ./cmd/api # ════════════════════════════════════════════════════════════════════════ # STAGE 2 — Final image # Starts completely fresh — no Go toolchain, no source code, no build cache # ════════════════════════════════════════════════════════════════════════ FROM scratch AS final # Copy TLS certificates from builder so HTTPS calls inside the app work # scratch has nothing — not even ca-certificates — so we must bring our own COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Copy ONLY the compiled binary from the builder stage COPY --from=builder /build/api_server /api_server # Document the port (doesn't publish it) EXPOSE 8080 # Run the binary directly — no shell available in scratch, so exec form is required CMD ["/api_server"]
[+] Building 38.4s (12/12) FINISHED
$ docker images go-api
REPOSITORY TAG IMAGE ID SIZE
go-api latest a1b2c3d4e5f6 9.2MB ← entire production image is 9MB
# Compare to a naive single-stage build:
REPOSITORY TAG IMAGE ID SIZE
go-api-fat latest 9f8e7d6c5b4a 842MB ← includes full Go toolchain
# That's a 98.9% reduction in image size
# 9MB vs 842MB means roughly 90x faster cold pulls from the registry
| Aspect | Docker Volume | Bind Mount | tmpfs Mount |
|---|---|---|---|
| Managed by | Docker daemon | You (host path) | Docker daemon (RAM) |
| Data persists after container stop | Yes | Yes (it's a host file) | No — gone immediately |
| Data persists after docker rm | Yes | Yes (it's a host file) | N/A — already gone |
| Best use case | Production databases, uploads | Dev hot-reload, config injection | Secrets, session tokens, scratch space |
| Portability | High — works on any Docker host | Low — tied to host path structure | High — host-agnostic |
| Performance | Good (slight overhead) | Best (direct host I/O) | Excellent (RAM speed) |
| Visible to 'docker volume ls' | Yes | No | No |
| Works in Docker Compose | Yes — named volume syntax | Yes — relative path syntax | Yes — tmpfs key |
🎯 Key Takeaways
- Instruction order in a Dockerfile is a performance decision — least-volatile instructions (OS packages, dependency installs) must come before most-volatile ones (source code copy) or you eliminate all caching benefit and rebuild from scratch on every code change.
- Named volumes survive 'docker rm'; bind mounts are host filesystem paths that survive by definition; tmpfs is RAM-only and is the only Docker storage mechanism that guarantees data never touches disk — critical for ephemeral secrets handling.
- Docker's built-in DNS lets containers on the same network resolve each other by service name, not IP address. Never hardcode container IPs — they change on every restart. Always reference services by name and let Docker handle the resolution.
- Multi-stage builds are not an optimisation you do later — they're the default pattern for any compiled language. Shipping a Go or Java app in a single-stage image that includes the compiler is equivalent to shipping a kitchen with every meal you deliver.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Running containers as root unnecessarily — Your app inside the container runs as root by default, meaning a vulnerability in the app gives an attacker root access inside the container. Fix it by adding USER instructions in your Dockerfile: create a non-root user with RUN addgroup -S appgroup && adduser -S appuser -G appgroup and switch to it with USER appuser before the CMD. Most application workloads need no root privileges at runtime whatsoever.
- ✕Mistake 2: Using shell form for CMD and ENTRYPOINT — Writing CMD npm start instead of CMD ["npm", "start"] wraps your process in /bin/sh -c, making sh the PID 1 process. When you run docker stop, Docker sends SIGTERM to PID 1 (sh), not to your app. sh doesn't forward signals, so your app gets a hard SIGKILL after the grace period (10 seconds by default), potentially corrupting in-flight requests. Always use exec form (array syntax) so your app is PID 1 and receives signals directly.
- ✕Mistake 3: Copying the entire build context including node_modules or .git — Running COPY . . without a .dockerignore file copies your entire local directory, including node_modules (potentially GBs), .git history, .env files with secrets, and local log files. This bloats the image, leaks sensitive data, and slows every build because Docker hashes the entire context. Fix: create a .dockerignore file at the project root listing node_modules, .git, .env, *.log, dist, and any other non-essential paths. It works exactly like .gitignore and is not optional in real projects.
Interview Questions on This Topic
- QExplain the difference between CMD and ENTRYPOINT in a Dockerfile. When would you use both together?
- QA container is writing logs to a file inside the container filesystem. After restarting the container, the logs are gone. How do you fix this, and what are the trade-offs between the storage options available to you?
- QYour CI pipeline is pulling a 1.2GB Docker image on every run and build times are unacceptable. Walk me through every optimisation you'd apply — to the image itself, the Dockerfile, and the registry setup.
Frequently Asked Questions
What is the difference between a Docker image and a Docker container?
An image is a read-only, layered template — think of it as a frozen snapshot of a filesystem and its metadata. A container is a live, running instance of that image with a thin writable layer added on top. You can create dozens of containers from a single image simultaneously, each isolated from the others. When a container is deleted, its writable layer is gone, but the original image is untouched.
How do containers differ from virtual machines in a Docker interview context?
VMs virtualise hardware — each VM runs a full OS kernel on top of a hypervisor, consuming gigabytes of RAM just for the OS. Containers share the host OS kernel and virtualise only at the process level using Linux namespaces and cgroups. A container starts in milliseconds and uses megabytes of memory; a VM takes minutes and uses gigabytes. The trade-off is isolation: VMs provide stronger security boundaries because each has its own kernel, while containers share the host kernel — a kernel vulnerability could potentially affect all containers on the host.
Why does Docker cache get invalidated unexpectedly during a build?
Docker invalidates a layer's cache when either the instruction itself changes or any file copied into that layer has changed (based on content hash). The most common culprit is COPY . . appearing before RUN npm install — any source file change invalidates the COPY layer, which cascades to invalidate the npm install layer and forces a full dependency reinstall. Fix this by copying only package.json and package-lock.json first, running npm install, then copying the rest of the source. Additionally, ADD instructions that fetch URLs are never cached because Docker can't detect if the remote content changed.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.