DEV Community

Cover image for Docker for SDE interviews: the guide I wish I'd had
Sarthak Rawat
Sarthak Rawat

Posted on AI-assisted

Docker for SDE interviews: the guide I wish I'd had

What it actually does under the hood, the Dockerfile traps interviewers love, and the debugging playbook that makes you sound like you've shipped something.

Docker questions show up in almost every backend interview now, even ones that aren't about infrastructure. You mention deploying something, and the interviewer asks how you packaged it. You draw a system design diagram, and there's a box labeled "container" that nobody explains. Half the time it's a genuine technical question. The other half it's a filter: does this person actually understand what they've been typing into a terminal, or have they only ever copied a Dockerfile from Stack Overflow?

This post covers what you actually need: how Docker works under the hood, writing a Dockerfile that isn't naive, images and layers, networking, storage, Compose, the CLI commands worth knowing, security basics, where Docker stops being enough, and a debugging playbook for the scenario questions interviewers like to throw at you.

What Docker actually is

Here's the confusion to clear up first, because it trips up more candidates than anything else: a container is not a lightweight virtual machine. It's a regular process on the host's Linux kernel, made to look isolated using three kernel features that have existed for years, which Docker packaged into something usable.

Namespaces give a process its own view of things that are normally global. A PID namespace makes a container's first process think it's PID 1, even though the host sees it as PID 48213. A network namespace gives it its own network interfaces and routing table. There are namespaces for mount points, hostnames, user IDs, and inter-process communication too. Namespaces are about what a process can see.

Control groups (cgroups) limit what a process can use: how much CPU, memory, and I/O bandwidth. docker run --memory=512m is a cgroup limit. Without one, a single container can eat all the host's RAM and take everything else down with it, which is exactly the kind of thing that happens in production and gets asked about in interviews.

A union filesystem stacks read-only image layers with one thin writable layer on top, so containers share the same base files on disk instead of each getting a full copy.

Put those three together and you get something that starts in milliseconds, because you're not booting a kernel, just starting a process with some walls around it.

That's also the honest answer to "container vs VM," which is asked in nearly every Docker interview. A VM virtualizes hardware: a hypervisor gives each VM its own kernel, so a VM can run a completely different OS than its host, and boots in the order of a minute because it's booting an entire operating system. A container virtualizes at the OS level: it shares the host's kernel, so a Linux container needs a Linux host kernel underneath it (Docker Desktop on Mac and Windows quietly runs a small Linux VM to give you that kernel), and it starts in milliseconds to a couple of seconds because there's no kernel boot involved. The tradeoff is isolation strength: a VM's hypervisor boundary is harder to break out of than a container's kernel-feature boundary, which is part of why nobody runs genuinely hostile, untrusted code in a bare container without extra layers like gVisor or Kata Containers on top.

This is also why "Docker Desktop" and "Docker Engine" aren't quite the same thing, a distinction worth having straight if you develop on a Mac or Windows laptop and deploy to Linux servers, which describes most people. On Linux, Docker Engine runs natively, talking directly to the host kernel's namespaces and cgroups. On Mac and Windows there is no Linux kernel to talk to, so Docker Desktop quietly runs a small Linux VM in the background and Docker Engine runs inside that VM instead. Functionally you barely notice the difference day to day, but it's the reason volumes on a Mac live inside that hidden VM rather than as a directly browsable folder on your actual filesystem, and it's part of why "works fine on my Mac, does something weird on the server" occasionally isn't actually a lie, there's a real extra layer on your laptop that the server doesn't have.

The architecture. Four pieces, and interviewers like to hear you name all four because it explains why Docker commands sometimes fail in confusing ways. The Docker CLI is the docker command you type. The Docker daemon (dockerd) is a background service that does the actual work: building images, running containers, managing networks and volumes. The Docker Engine is the daemon plus its REST API and the CLI together, the whole toolset. A registry stores and distributes images; Docker Hub is the default public one. When you run docker build, the CLI doesn't build anything itself, it sends a request to the daemon over that REST API, and the daemon does the work. That's why "Docker isn't working" is so often really "the daemon isn't running" or "my user doesn't have permission to reach it," and knowing that split is what separates someone who's memorized commands from someone who understands the tool.

One more distinction worth locking in early, because interviewers ask it directly: an image is a static, read-only blueprint, built once and then reused. A container is a running instance of that image, a live process with its own writable layer on top. One image can spin up many containers at once, each isolated from the others, the way one class definition can produce many objects. A volume is neither of those. It's a mechanism for storing data outside a container's writable layer, so that data survives when the container is removed. We'll come back to volumes properly in the storage section, but keep the three separate in your head: image is the recipe, container is the meal, volume is the fridge you keep leftovers in after the meal's plate gets thrown away.

Images, layers, and registries

An image is a stack of read-only layers plus some metadata (which command starts the container, which ports it documents, what its default working directory is). Each instruction in a Dockerfile that changes the filesystem produces one layer, and each layer is content-addressed: Docker hashes its contents, and if two images share a layer with the same hash, they share the actual bytes on disk instead of duplicating them. That's why pulling a new image that shares a base with one you already have is often fast, you're only downloading the layers you don't already have.

This is the whole logic behind the build cache, and it's the single most common "do you actually understand Docker" test. Docker builds a Dockerfile top to bottom, and before running each instruction it checks whether it's seen that exact instruction with the exact same inputs before. If so, it reuses the cached layer instead of redoing the work. The moment one instruction changes, every instruction after it has to rerun, even if nothing else changed, because each layer builds on top of the one before it. That's why you copy requirements.txt and install dependencies before copying the rest of your application code: your code changes on every commit, but your dependencies don't, so if you copy code first, every build reinstalls every dependency from scratch. Order layers from least-likely-to-change to most-likely-to-change, and your builds stay fast for months.

# Slow: any code change reinstalls every dependency
COPY . .
RUN pip install -r requirements.txt

# Fast: dependency layer stays cached until requirements.txt itself changes
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Enter fullscreen mode Exit fullscreen mode

Storage and registries. Images live locally under Docker's data directory until you push them somewhere. A registry is just a storage and distribution service for images: docker pull fetches from one, docker push sends to one. Docker Hub is the default and where most official base images live (python, node, postgres, nginx). In a real company you'll almost always be pushing to a private registry instead, like Amazon ECR, Google Artifact Registry, GitHub Container Registry, or a self-hosted one like Harbor, so images never leave your own infrastructure.

Tagging is where people get careless, and it's a favourite interview trap. An image reference is name:tag, and if you don't specify a tag, Docker assumes latest. The trap is that latest doesn't mean "the newest version," it's just a tag like any other that happens to be the default. If you keep pushing new builds tagged latest, the previous image becomes an untagged, unreferenced layer sitting on disk somewhere, and you've lost your ability to roll back to it by name. In production you want an explicit version or, more commonly now, the git commit SHA as the tag: myapp:a3f9c21. That gives you full traceability. You always know exactly which commit is running in any environment, and rolling back means simply redeploying the previous SHA.

Writing a Dockerfile that isn't naive

A Dockerfile is a plain text file of instructions Docker follows, top to bottom, to build an image. Here's a reasonably real one for a small Python service:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
Enter fullscreen mode Exit fullscreen mode

Walking through what each line is actually doing: FROM sets the base image everything else builds on top of, here a slim, minimal Python image rather than the full one, which matters for size (more on that shortly). WORKDIR sets the working directory inside the image for every instruction after it, and creates the directory if it doesn't exist. COPY copies files from your build context into the image; RUN executes a command at build time and bakes its result into a new layer. EXPOSE is worth being precise about, because it's a common source of confusion in interviews: it does not publish a port. It's purely documentation, a note in the image's metadata saying "this app listens on 8000." The thing that actually makes a port reachable from outside the container is the -p flag at runtime, docker run -p 8080:8000 myapp, which maps host port 8080 to container port 8000. You can EXPOSE a port and never publish it, and you can publish a port you never bothered to EXPOSE. CMD sets the default command the container runs when it starts.

COPY vs ADD. Both copy files into the image, but ADD quietly does more: it auto-extracts local .tar archives into the destination, and it can fetch files from a remote URL. That second behaviour is exactly why most teams avoid it: pulling from a URL at build time means your build isn't reproducible, since the same Dockerfile can produce a different image tomorrow if that URL's contents change. Use COPY by default. Reach for ADD only on the rare occasion you specifically need local tar extraction.

ENV vs ARG. Both set variables, but their lifetimes differ. ARG exists only during the build and isn't present in the final image or in a running container, useful for things like choosing a base image version at build time. ENV sets an environment variable that persists into the running container, so anything your application reads from the environment at runtime should be ENV, not ARG. A subtlety worth knowing: ARG values do show up in docker history, so never pass secrets through build args either, they're not hidden, just short-lived.

CMD vs ENTRYPOINT is the classic trap, and it's worth getting exactly right. Both define what runs when a container starts, but they behave differently when you pass arguments at runtime. CMD sets a default command that gets fully replaced if you specify anything after the image name in docker run. ENTRYPOINT sets a fixed executable that always runs; anything you pass at runtime gets appended to it as arguments instead of replacing it.

# CMD alone — fully overridable
CMD ["python", "app.py"]
# docker run myimage python other.py  →  runs "python other.py", ignoring app.py entirely

# ENTRYPOINT + CMD — entrypoint is fixed, CMD is just its default argument
ENTRYPOINT ["python"]
CMD ["app.py"]
# docker run myimage other.py  →  runs "python other.py"
# docker run myimage           →  runs "python app.py"
Enter fullscreen mode Exit fullscreen mode

The common, sensible pattern is ENTRYPOINT for the fixed executable and CMD for a default argument you're happy to have overridden. If you genuinely need to override the entrypoint itself at runtime, there's an explicit --entrypoint flag for that; it doesn't happen by accident. For a lot of simple apps CMD alone is all you need, and ENTRYPOINT only earns its place when you want to lock in the executable and treat everything else as swappable arguments.

There's a second, quieter trap sitting right next to this one: the JSON array form above, CMD ["python", "app.py"], is called exec form, and it matters for more than syntax. Write it instead as a plain string, CMD python app.py, and Docker treats it as shell form, silently wrapping it as /bin/sh -c "python app.py". That means your app isn't actually PID 1 inside the container, the shell is, with your app running as a child process underneath it. When docker stop sends SIGTERM, it goes to PID 1, the shell, which generally has no idea it's supposed to forward that signal on to your app. Your app never hears about the shutdown, so it just keeps running until the grace period expires and Docker escalates to SIGKILL, meaning every stop takes the full timeout and nothing gets a clean chance to exit. Exec form avoids all of this because your app becomes PID 1 directly and receives signals itself. Default to exec form for both CMD and ENTRYPOINT unless you have a specific reason to want the shell in between, like needing shell features such as environment variable expansion in the command itself.

The build context and .dockerignore. When you run docker build ., that trailing . is the build context: the entire directory Docker sends to the daemon before the build even starts. Every file in it gets transferred, whether your Dockerfile uses it or not, which matters for two reasons. A large context slows down every single build, since it all has to ship over before anything happens. And only files inside the context are available to COPY; anything outside it is invisible to the build. A .dockerignore file works exactly like .gitignore and trims the context down, typically excluding .git, virtual environments, local caches, test directories, and any build artifacts.

Multi-stage builds and cutting image size

Multi-stage builds are the answer to a genuinely common interview scenario: "here's a 1.2 GB image, get it under 200 MB." A Dockerfile can have more than one FROM, and each one starts a fresh stage. You can copy specific files from an earlier stage into a later one, and the final image only contains whatever the last stage explicitly copied in. Compilers, build tools, and test dependencies from earlier stages never make it into the image that actually ships.

# Stage 1: build, with all the compiler tooling you need
FROM python:3.12 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/build/packages -r requirements.txt

# Stage 2: the image that actually ships
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /build/packages /usr/local/lib/python3.12/site-packages
COPY . .
CMD ["python", "app.py"]
Enter fullscreen mode Exit fullscreen mode

The savings are most dramatic for compiled languages, where a Go or Java build stage can drag in gigabytes of toolchain that a runtime image never touches. For an interpreted language like Python the main win is excluding build-time-only packages like gcc, needed to compile certain dependencies but useless afterward.

The other lever, alongside multi-stage builds, is your base image choice. python:3.12 is roughly 900 MB. python:3.12-slim strips out most tools you don't need at runtime and lands around 130 MB. Alpine-based images go smaller still, but Alpine uses musl instead of glibc, which occasionally breaks Python packages with compiled C extensions, so it's not a free upgrade; slim is usually the safer default. Beyond slim there are distroless images, which strip out even the shell and package manager, leaving close to nothing but your app and its runtime, the smallest attack surface but also the hardest to debug, since you can't exec into a shell that isn't there. Combine a slim or distroless final stage with multi-stage builds and a .dockerignore, and a 1.2 GB image getting down to under 200 MB is a completely normal outcome, not a special trick.

Container lifecycle and the essential CLI

Before the states themselves, one thing worth being clear on, because it's the source of a very common fresher confusion: docker run ubuntu with nothing else after it starts a container and it exits immediately, often before you've even finished reading the output. This looks broken, but it isn't. A container stays alive for exactly as long as its main process, PID 1, stays alive, and nothing else. The ubuntu image's default command is a shell with no terminal attached to keep it open, so that shell starts, has nothing to do, and exits, and the moment PID 1 exits, the whole container stops, regardless of anything else that happened to be running. This is genuinely different from a VM, where the "machine" stays up as its own thing independent of whatever process you happen to be running inside it. A container has no concept of "staying up" separate from its main process; the process is the container's lifetime.

This is also why you'll rarely see a well-designed container running more than one real service. It's tempting, especially early on, to think "why not run nginx and my app and a cron job all in one container," but Docker only supervises one PID 1, so if you cram three services into one entrypoint script, Docker has no idea if two of them silently died, it only knows whether the wrapper script itself is still alive. The convention, and the thing to say in an interview if it comes up, is one process per container: if you need several services, run several containers and let Compose or an orchestrator manage them as a group, each with its own lifecycle Docker can actually see and act on.

With that settled, a container moves through a small number of states, and knowing the exact commands and signals for each transition is table stakes.

State How you get there What's happening
Created docker create Filesystem is set up from the image, nothing is running yet
Running docker run (create + start) or docker start The main process is executing
Paused docker pause Processes are frozen in place, memory held
Stopped docker stop or docker kill Main process has exited, filesystem still on disk
Removed docker rm Container and its writable layer are gone

docker run and docker start get mixed up constantly, so it's worth being precise: docker run always creates a brand-new container from an image and starts it. docker start restarts a container that already exists but is currently stopped. If you docker run the same image five times, you get five separate containers; if you docker start a stopped one, you get the same container back, with the same writable layer and any data it had.

docker stop and docker kill also aren't the same thing, and interviewers like this pairing because it has a real production consequence. docker stop sends SIGTERM, gives the process a grace period (10 seconds by default) to shut down cleanly, and only sends SIGKILL if it hasn't exited by then. docker kill sends SIGKILL immediately, no grace period, no chance to flush a buffer or finish a write. For anything talking to a database mid-transaction, that difference matters.

Containers don't restart on their own unless you tell them to, via --restart:

Policy Behaviour
no Never restart automatically (the default)
always Restart whenever it stops, even after the Docker daemon itself restarts
on-failure[:N] Restart only on a non-zero exit code, optionally capped at N retries
unless-stopped Like always, but won't restart a container you stopped manually

unless-stopped is usually the sensible production default, since always will happily restart a container you stopped for maintenance the moment the daemon comes back up, which is rarely what you want.

Exit codes are worth glancing at when a container has already died: 0 is a clean exit, and 137 specifically means the process was killed by SIGKILL, most often because the kernel's OOM killer stepped in after the container hit its memory limit. Seeing 137 in docker ps -a should make you reach straight for docker stats and your memory limits, not the application logs.

Here's the CLI reference worth actually knowing, not memorizing flag-by-flag, but knowing what each one is for and why you'd reach for it:

Command What it's for
docker build -t name . Build an image from a Dockerfile, tagging it name
docker run -d -p 8080:80 name Create and start a container, -d runs it detached in the background instead of tying up your terminal in the foreground
docker run --env-file .env name Load many environment variables from a file at once, instead of a long chain of -e KEY=value flags
docker ps / docker ps -a List running containers / all containers including stopped ones
docker exec -it name sh Run a new process (usually a shell) inside a running container, for debugging
docker logs --tail 50 -f name Show recent stdout/stderr, follow live with -f
docker cp file.txt name:/app/ Copy a file between the host and a running container, in either direction, without needing a shell
docker stop / docker kill Graceful shutdown (SIGTERM) vs immediate (SIGKILL)
docker rm / docker rmi Remove a container / remove an image
docker inspect name Full JSON metadata: IPs, mounts, env vars, health status, everything
docker stats Live CPU, memory, and I/O usage per container
docker pull / docker push Fetch an image from a registry / send one to a registry
docker image prune / docker system prune Clean up dangling images / clean up all unused containers, networks, and images

A couple of these are worth a sentence more than the table gives them. -d matters because without it your terminal is attached to the container's output and blocked until it exits, fine for a quick test, useless for anything you want running in the background while you keep working. --env-file is the practical way to pass a real application's worth of configuration, database URLs, API keys, feature flags, without a docker run command that's twenty -e flags long; Compose has the equivalent env_file: key for the same reason. docker cp is the one people forget exists and then do something roundabout instead, like rebuilding an image just to add one file, when copying it straight into a running container takes one command.

One easy-to-miss distinction: docker exec starts a brand-new process inside an already-running container, which is what you want for debugging, poking around, or running a one-off script. docker attach instead connects your terminal directly to the container's main process (PID 1). If you Ctrl+C out of an attached session instead of detaching properly with Ctrl+P, Ctrl+Q, you can send a kill signal straight to that main process and stop the container by accident. For everyday debugging, exec is almost always the right tool.

A last cleanup note that catches people running CI pipelines: a dangling image is an old, untagged image layer left behind when you rebuild an image with the same name and tag, orphaning the previous version. They pile up quietly and can fill a CI runner's disk within days if nothing ever cleans them up. docker image prune handles just the dangling ones; docker system prune -a is far more aggressive and removes every image not currently used by a running container, so use it carefully.

Networking

By default, Docker gives you a bridge network, a private virtual network on the host that containers attach to. The default bridge is fairly limited: containers on it can only reach each other by IP address. A user-defined bridge network, which you create yourself with docker network create, is much more useful, because Docker runs an embedded DNS server on it, so containers can resolve each other by name instead of by IP. This is exactly what makes Docker Compose feel effortless: every service in a Compose file gets attached to the same user-defined network automatically, and a web service can just connect to postgres://db:5432 using db as a hostname, no IP addresses anywhere.

There are four network drivers worth knowing:

  • Bridge (the default, described above): single-host, containers reach each other by name on a shared virtual network.
  • Host: the container skips network isolation entirely and shares the host's network stack directly. Fastest possible networking, since there's no virtual bridge in between, but no port mapping and no isolation, since the container binds directly to host ports.
  • None: no networking at all, useful for fully isolated batch jobs that need zero network access.
  • Overlay: extends a virtual network across multiple Docker hosts, which is what makes Swarm's multi-host clustering possible in the first place. You won't reach for this on a single machine.

The one detail that trips people up in a real debugging scenario: inside a container, localhost refers to that container itself, not to another container or to the host machine. If service A needs to reach service B, it has to use B's container name (or Compose service name) on a shared user-defined network, localhost will just fail silently or connect to nothing.

Port publishing is worth restating cleanly here since it connects back to EXPOSE: -p 8080:8000 maps host port 8080 to container port 8000, and that's the only thing that actually makes a container reachable from outside Docker's network. Nothing else does it.

Storage: volumes, bind mounts, and tmpfs

Anything written to a container's writable layer disappears the moment that container is removed. That's fine for a stateless web server, and a real problem for a database. Docker gives you three ways to persist or share data, and interviewers care about you knowing which one fits which situation.

Type Where it lives Survives container removal? Best for
Named volume Docker-managed location on the host Yes Databases, production data
Bind mount A specific path you choose on the host Yes, it's just the host filesystem Local development, live code reload
tmpfs RAM only, never touches disk No, gone the moment the container stops Secrets, sensitive temp data

A named volume (docker volume create my_data, then docker run -v my_data:/app/data) is storage Docker manages for you, outside of any specific container's filesystem, kept at /var/lib/docker/volumes/ on Linux. It's the right default for a database or anything you'd genuinely be upset to lose, and it's independent of any one container's lifecycle. On macOS and Windows, Docker Desktop runs the engine inside a lightweight Linux VM under the hood, so volumes live inside that VM rather than as a directly browsable host path, worth knowing so you're not confused when you go looking for the files and they're not where you expected.

A bind mount links a specific path on the host directly into the container: docker run -v $(pwd):/app. It's what makes local development pleasant, since editing a file on your host shows up inside the running container instantly, no rebuild needed. The tradeoff is that it depends entirely on your host's directory layout and gives the container direct read/write access to a real host path, which is a real security concern if that container is ever compromised, and part of why bind mounts are common in development and far less common in production.

tmpfs mounts a filesystem that only ever lives in memory, never touching disk, and vanishes completely the instant the container stops. docker run --tmpfs /app/tmp. Reach for it when you genuinely don't want data persisted anywhere, like a decrypted secret you only need for the lifetime of one process.

Docker Compose

A single Dockerfile builds one image. Real applications are rarely one container, so Compose exists to define, network, and start several containers together with one command, all described in a YAML file.

services:
  web:
    build: .
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy
    environment:
      - DB_HOST=db

  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=devpass
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

docker compose up reads this file, creates a shared user-defined network automatically, builds or pulls whatever images are needed, and starts every service, in dependency order where you've declared it. docker compose down tears the whole thing back down. Worth knowing as current: the standalone docker-compose Python binary (with the hyphen) has been end-of-life since 2021, and what you actually run today is docker compose (no hyphen), a plugin built into the Docker CLI itself. If you see a tutorial using the hyphenated form, it's outdated; both still work on most installs for backward compatibility, but the space-separated version is the one you'll actually see in current documentation and in any interview that's testing whether you're current.

The depends_on: condition: service_healthy pattern in the example above is worth understanding, not just copying. A plain depends_on: db only waits for the db container to start, not for Postgres inside it to actually be ready to accept connections, which is a very common cause of a container that keeps crash-looping on startup because it tried to connect to a database that technically existed but wasn't listening yet. Pairing a healthcheck on the dependency with condition: service_healthy on the dependent service fixes exactly that: web won't start until Docker has actually confirmed db is healthy, not just running.

A few Compose commands worth knowing by name, since they come up constantly in real work and in interviews: docker compose up -d starts everything in the background, docker compose down stops and removes containers and the network (add -v to also drop volumes), docker compose build rebuilds images without starting anything, and docker compose logs -f service_name tails logs from just one service instead of the whole stack.

One more feature worth a mention: Compose profiles let you tag certain services so they only start when you explicitly ask for them, useful for things like debugging tools or a monitoring stack you don't want cluttering a normal docker compose up.

services:
  debug-tools:
    image: my-debug-image
    profiles: ["debug"]
Enter fullscreen mode Exit fullscreen mode

docker compose up skips it entirely; docker compose --profile debug up includes it. Handy for keeping one Compose file instead of maintaining several near-duplicate ones.

Security basics

Security questions have become more common in Docker interviews, not just for DevOps-flavoured roles, and the expectations are fairly specific.

Run as a non-root user. By default, whatever runs inside a container runs as root, and root inside a container that escapes its isolation is root on the host. It's a small addition to a Dockerfile:

RUN adduser --system --group appuser
USER appuser
Enter fullscreen mode Exit fullscreen mode

Use a read-only filesystem where you can. docker run --read-only makes the container's entire filesystem read-only except for anything you've explicitly mounted as a volume or tmpfs. If your app has no legitimate reason to write to its own filesystem, this closes off an entire class of attack where a compromised process tries to write and execute something malicious inside the container.

Keep secrets out of environment variables and image layers. Environment variables are convenient but not secure: they show up in docker inspect output and often end up in logs. And anything baked into an image layer at build time, including via ARG, is recoverable from docker history even if you delete it in a later layer, since earlier layers are still there underneath. For a Swarm setup, Docker Secrets encrypts secrets at rest and mounts them as files under /run/secrets/ only inside containers that explicitly request them. Outside Swarm, most teams reach for a dedicated secrets manager: HashiCorp Vault, AWS Secrets Manager, or similar.

Scan your images before they ship. Tools like Trivy and Snyk check your base image and dependencies against known CVE databases, and wiring a scan step into CI catches a vulnerable base image before it ever reaches production instead of after.

Never run latest in production, for the same rollback reason covered in the tagging section earlier, and it's worth repeating here because it's as much a security practice as a hygiene one: you want to know, with certainty, exactly which image is running where.

One more thing worth a mention if the role leans toward platform or security: rootless Docker runs the daemon itself as a non-root user on the host, so even if something escapes a container, it only has the privileges of an unprivileged host user, not host root. It comes with some tradeoffs (a handful of networking and storage features aren't available), but it's increasingly the recommended default for security-conscious setups, especially shared CI runners.

Where Docker stops being enough

A single Docker host works fine until you need more than one, and that's where orchestration comes in. Docker Swarm is Docker's own built-in orchestrator: turn a group of Docker hosts into a cluster, and Swarm handles basic load balancing, service placement, and restarting failed containers. It's genuinely simple to set up, since it's already part of the Docker CLI.

Kubernetes is a separate, far more capable orchestration platform: sophisticated auto-scaling, fine-grained scheduling, self-healing, rolling updates, and support for deployments at a scale Swarm was never really built for. The CNCF's 2025 annual survey put Kubernetes adoption in production at 82% of container users, up from 66% just two years earlier, and that gap keeps widening rather than closing. For most SDE interviews, especially junior and mid-level ones, you're not expected to know Kubernetes deeply unless the job description specifically calls for it. What you are expected to know is the shape of the tradeoff: Swarm is simpler and genuinely fine for smaller deployments, Kubernetes is what nearly everyone reaches for once things get big enough to need serious orchestration, and being able to say that clearly, with the reasoning behind it, covers this topic completely for the vast majority of interviews.

The debugging playbook

This is where interviews increasingly go: not "define a Dockerfile instruction," but "here's a container doing something wrong, walk me through how you'd figure out why." The specific scenario changes, but a consistent method covers nearly all of them: logs first, then network, then a shell, then metrics, in that order, because each step is cheaper and faster than the next, and most problems resolve before you reach the expensive ones.

A container is running but not responding to requests. Start with docker logs --tail 50 -f name. If the application never actually started, or crashed and restarted, the logs usually say so immediately. If the app looks fine in the logs, check whether the port is actually published: docker inspect name and look at the port bindings, since a container that's healthy internally but never had -p set is invisible from outside no matter how correctly it's running. If it still looks fine, get a shell inside with docker exec -it name sh and check from there directly. If you've configured a HEALTHCHECK, docker inspect --format='{{.State.Health.Status}}' name gives you Docker's own verdict without any guesswork.

One container can't reach another. This is a networking problem almost every time, not an application bug, and it's worth saying that out loud in an interview because it shows you're not about to go digging through application code for a routing issue. First, confirm both containers are actually on the same network with docker network inspect network_name, since containers on different networks simply can't see each other by default. Second, and this catches almost everyone at least once: confirm you're connecting using the container name (or Compose service name) as the hostname, not localhost, since localhost inside a container always means that container, never a different one. Third, confirm the target is actually listening on the port you expect from inside that container, using ss -tlnp (the modern replacement for netstat, which most slim images don't even include anymore). If the image is minimal enough that even ss isn't there, check from the host side instead with docker port container_name.

COPY fails during a build even though the file is right there in the repo. Check .dockerignore first, it's the single most common cause by a wide margin: the file is present in your repo but explicitly excluded from the build context. Second, remember the build context is whatever directory you pointed docker build at, not necessarily wherever the Dockerfile itself lives, so if you're running the build from a parent directory with --file path/to/Dockerfile, your context is that parent directory, and any path in COPY is relative to it, not to the Dockerfile. Third, check the exact filename: Linux is case-sensitive, so Data.csv and data.csv are genuinely different files, a mismatch that's invisible on a case-insensitive filesystem like macOS's default but breaks immediately inside a Linux container.

A container keeps restarting in production. docker ps -a first, to see the exit code of whatever's crash-looping, then docker logs --tail 100 name. Three causes cover almost every real case. Exit code 137 means the OOM killer stepped in because the container hit its memory limit, confirm with docker stats and raise the limit if the workload genuinely needs it. A dependency not being ready, like a database the app tries to connect to before it's actually accepting connections, is fixed with the depends_on: condition: service_healthy pattern from the Compose section. And a genuine application crash, a bad input, an unhandled null, a schema mismatch, will usually be sitting plainly in the logs once you look, and needs an actual code fix rather than an infrastructure one. The useful distinction to name out loud in an interview: infrastructure failures (memory, timing, dependencies) and application failures (bugs, bad data) get diagnosed differently and fixed in completely different places, and being able to tell which kind you're looking at quickly is most of what "debugging skill" means here.

Using Docker in a CI/CD pipeline deserves its own mention, since it's a near-guaranteed follow-up once a Docker conversation gets this far. The pattern that comes up over and over: build an image once, tag it with the commit SHA, run your test suite inside that exact image rather than in some separate CI environment, and only push to your registry once tests pass.

# GitHub Actions, roughly
- name: Build image
  run: docker build -t myapp:${{ github.sha }} .

- name: Run tests inside the built image
  run: docker run --rm myapp:${{ github.sha }} pytest tests/

- name: Push to registry
  run: |
    docker tag myapp:${{ github.sha }} ghcr.io/org/myapp:${{ github.sha }}
    docker push ghcr.io/org/myapp:${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

The point of testing inside the image you're about to ship, rather than in a separate CI runner environment, is that you're validating the actual artifact that goes to production, not something that merely resembles it. Tagging with the commit SHA the whole way through means you can trace, at any point later, exactly which commit is running in any given environment, and a "what changed between this deploy and the last one" question has a one-line answer instead of an investigation.

Further reading

Top comments (0)