Docker and Containers
Docker questions in a backend interview aren't really about Docker syntax. They're about whether you understand image layering, why image size matters in production, and how to wire a multi-service local environment together. This lesson covers layer caching, a real multi-stage Dockerfile for a Python app, COPY versus ADD, shrinking an image, networking and volumes, and a docker-compose file wiring an app, a database, and Redis together.
Images are layers, and layers are cached
Every instruction in a Dockerfile, RUN, COPY, ADD, creates a new, immutable layer stacked on the one before it. Docker caches each layer by the hash of its inputs: if a layer's inputs haven't changed, Docker reuses the cached layer instead of re-running the instruction. This is the entire reason instruction order matters:
# BAD: any source code change invalidates the pip install cache layer,
# forcing a full dependency reinstall on every build
COPY . .
RUN pip install -r requirements.txt
# GOOD: dependencies only reinstall when requirements.txt itself changes
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .Copy the files that change the least often first. In the bad version, editing a single line of application code invalidates the cache for the pip install layer too, since Docker sees COPY . . as changed and everything after it as no longer trustworthy. In the good version, requirements.txt only changes when a dependency actually changes, so the expensive install step stays cached across ordinary code edits. "How would you speed up this Dockerfile" is close to a guaranteed question, and this ordering trick is the answer almost every time.
Remember: copy your least-frequently-changing files first. A source code change should never force a dependency reinstall.
A multi-stage build for a Python app
# --- Stage 1: build dependencies ---
FROM python:3.12-slim AS builder
WORKDIR /app
# System deps needed only to COMPILE some Python packages (e.g. psycopg2, cryptography) —
# these do not need to exist in the final image
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# --- Stage 2: runtime image ---
FROM python:3.12-slim AS runtime
WORKDIR /app
# Only runtime system deps — no compiler, no build headers
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Copy only the installed Python packages from the builder stage, not the build toolchain
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Run as a non-root user — a container running as root is a real security finding in any review
RUN useradd --create-home appuser && chown -R appuser /app
USER appuser
EXPOSE 8000
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]The compiler toolchain (build-essential, the -dev headers) needed to build psycopg2 or cryptography from source lives only in the builder stage. The final runtime image copies just the compiled .local package directory: not gcc, not the headers, not the apt cache. This is the mechanism behind "how do you reduce image size": a multi-stage build lets you use a full build environment during the build without shipping any of it in the final image.
Remember: the build toolchain lives only in the builder stage. The runtime stage copies the compiled result, not the compiler that produced it.
COPY vs ADD
COPY copies files or directories from the build context into the image, literally, nothing more. ADD does everything COPY does, plus two extra tricks: it can fetch a remote URL as a source, and it auto-extracts local tar archives into the destination. That extra behavior is exactly why ADD is generally discouraged: the implicit auto-extraction and remote-fetch behavior are easy to trigger by accident, and make a build step non-obvious just from reading the Dockerfile.
# COPY: explicit, no surprises
COPY requirements.txt .
# ADD: auto-extracts local .tar.gz — sometimes genuinely useful
ADD app-bundle.tar.gz /app/
# Prefer this over `ADD https://...` for remote files — visible, and you control error handling
RUN curl -fsSL https://mindforge.test/tool.tar.gz -o /tmp/tool.tar.gz \
&& tar -xzf /tmp/tool.tar.gz -C /usr/local/bin \
&& rm /tmp/tool.tar.gzThe rule to state in an interview: use COPY unless you specifically need tar auto-extraction, and never use ADD for a remote URL. An explicit RUN curl/wget makes the fetch and its error handling visible in the Dockerfile itself.
Remember: COPY is explicit and predictable. ADD hides two extra behaviors (remote fetch, auto-extract) inside one instruction, which is exactly why it's the one to avoid by default.
Reducing image size: the full checklist
- Multi-stage builds, above: the single biggest lever.
python:3.12-slimor-alpineinstead of the fullpython:3.12image, a difference of hundreds of megabytes. Alpine usesmusllibc instead ofglibc, which occasionally breaks binary wheels that expectglibc, soslim(Debian-based) is the safer default for Python.--no-cache-dironpip install: pip caches downloaded wheels by default, which is dead weight in an image that's built once and never reused for incremental installs.rm -rf /var/lib/apt/lists/*right after anyapt-get install, in the sameRUNlayer. A separateRUN rmin a later instruction doesn't shrink the earlier layer, since layers are immutable once committed.- A
.dockerignoreexcluding.git,__pycache__,.venv, test fixtures, and local env files. This keeps the build context small and stops secrets or dev artifacts from accidentally getting baked into a layer.
# .dockerignore
.git
__pycache__
*.pyc
.venv
.env
tests/
*.mdRemember:
rm -rf /var/lib/apt/lists/*only shrinks the image if it runs in the same RUN layer as the apt-get install it's cleaning up after. A later, separate RUN can't undo an earlier committed layer.
Docker networking and volumes
Networking. Containers on the same user-defined bridge network, which docker-compose creates automatically per project, can reach each other by service name as a DNS hostname: db, redis, web resolve automatically, with no manual IP wiring needed. Containers stay isolated from the host and from other Docker networks by default; you explicitly EXPOSE/publish (-p) only the ports that actually need host access.
Volumes. A named volume (docker volume create, or declared in compose) persists data outside any single container's writable layer. This matters for a database container specifically, since the container's own filesystem is ephemeral and gets destroyed on docker rm. A bind mount (./src:/app/src) maps a host directory directly into the container, mainly used for local dev hot-reload. Avoid bind-mounting source code in production images; production images should be immutable, self-contained builds.
Remember: service names resolve automatically as DNS hostnames on a compose network. A named volume survives
docker rm; the container's own writable layer does not.
docker-compose: app, database, and Redis together
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://appuser:apppass@db:5432/appdb
REDIS_URL: redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./:/app # dev-only bind mount for hot reload — remove for a production compose file
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
POSTGRES_DB: appdb
volumes:
- postgres_data:/var/lib/postgresql/data # named volume — survives `docker compose down`
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:depends_on with condition: service_healthy, not plain depends_on, is what prevents the classic "web container crashes on startup because Postgres isn't ready yet" race. Plain depends_on only guarantees start order, not readiness order, and a database container is considered "started" well before it's actually accepting connections. The healthcheck block is what makes service_healthy mean something concrete: without it, there's nothing for condition: service_healthy to check.
Remember: plain
depends_onwaits for a container to start, not to be ready.condition: service_healthywaits for the healthcheck to pass, which is what actually prevents the startup race.