This page is for developers, DevOps and platform engineers facing a Docker round, from a first job to a senior role. Most rounds open with containers versus virtual machines and images versus containers, then test how layers and caching work, what a good Dockerfile looks like, and the CMD versus ENTRYPOINT trap. After that come volumes, networking, Compose and secrets, and finally debugging a container that keeps dying and keeping images secure. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Swap the stories for your own.
Search all questions by round, difficulty and level, or save the ones you want to practise.
VM: a hypervisor runs a full guest operating system with its own kernel for each machine.
Container: an isolated process on the host, sharing the host kernel, fenced off with namespaces and limited with cgroups.
Trade-off: containers start in seconds and pack densely; VMs give a stronger isolation boundary and can run a different kernel.
"A virtual machine emulates hardware, so each one boots its own full operating system with its own kernel on top of a hypervisor. A container doesn't do that. It's really just a process on the host that the kernel isolates: namespaces give it its own view of processes, network and files, and cgroups cap how much CPU and memory it can use. Because there's no guest kernel to boot, a container starts in about a second and I can run many more of them on the same box. The catch is that every container shares the host kernel, so the isolation is thinner. I'd still pick a VM when I need a different kernel or operating system, or a hard security boundary between untrusted tenants. In practice the two stack: most containers in the cloud run on VMs."
Saying each container has its own operating system or kernel, or that containers are simply smaller VMs.
Image: a read-only stack of filesystem layers plus config such as the default command, environment and ports.
Container: a created or running instance of an image, with a thin writable layer on top.
Lifecycle: one image can back many containers; a stopped container still exists until you remove it.
"An image is the template. It's a read-only stack of filesystem layers, plus some config like the default command, environment variables and the working directory. A container is what I get when I run that image: Docker adds a thin writable layer on top, sets up the isolation, and starts the process. I can start ten containers from the same image and they all share the same read-only layers, each with its own writable layer, so they don't see each other's changes. It's a bit like a class and its objects. One thing people miss is that a stopped container isn't gone. It still shows up in docker ps -a with its writable layer intact until I run docker rm, and only then is anything written inside it lost."
Using the two words as if they mean the same thing, or thinking that editing files in a container updates the image.
Namespaces: control what a process can see: its own PID tree, network stack, mounts, hostname, IPC and user IDs.
Cgroups: control how much it can use: memory, CPU, number of processes, disk I/O.
The rest: a layered filesystem for the root, plus dropped capabilities and a seccomp filter to trim what it may do.
"A container is a normal Linux process with a few kernel features wrapped around it. Namespaces decide what it can see. In its own PID namespace it thinks it's process 1, in its own network namespace it gets its own interfaces and ports, the mount namespace gives it its own root filesystem, and there are namespaces for hostname, IPC and user IDs too. Cgroups decide how much it can use: a memory ceiling, a CPU share or quota, a cap on the number of processes. On top of that, the root filesystem is built from the image layers with an overlay filesystem, and Docker drops most Linux capabilities and applies a seccomp profile so the process can't make certain system calls. You can prove it's just a process: run a container, then run ps on the host and you'll see it there with a normal host PID."
Describing Docker as a hypervisor, or not being able to say what limits memory versus what hides other processes.
Layers: RUN, COPY and ADD each add a filesystem layer; the build reuses a layer when its inputs are unchanged.
Cache key: for RUN it's the command text on the same parent; for COPY and ADD it's also the contents of the files copied.
Chain: once one step misses the cache, every step after it rebuilds, so put rarely changing steps first.
"Each RUN, COPY or ADD produces a layer, and when I rebuild, Docker checks whether it already has that layer for the same parent. For a RUN step it compares the command text, and for COPY it also checksums the files being copied. If nothing changed, it reuses the layer instantly. The key rule is that once one step misses the cache, every step after it is rebuilt. So order matters a lot. If I copy my whole source tree before installing dependencies, any one-line code change invalidates the install step and I wait for every package again. If I copy just the dependency manifest, install, and only then copy the source, a code change reuses the install layer. One gotcha: because RUN is keyed on the text, a step like apt-get update can stay cached for weeks, so I keep update and install in the same RUN."
Believing Docker notices when a remote package changed for a cached RUN step, or copying all source code before installing dependencies.
Base: a slim official image with a fixed version tag rather than latest.
Cache: copy the package manifest and lock file, install, then copy the rest of the source.
Runtime: production dependencies only, a non-root user and an exec-form CMD.
"I start from a slim Node image with a fixed version tag so I'm not pulling a whole Debian toolchain, and I set a working directory. Then I copy only package.json and the lock file and run npm ci with dev dependencies left out. npm ci installs exactly what the lock file says, so builds are repeatable. Only after that do I copy the rest of the source, which means a normal code change reuses the install layer and the build takes seconds. I switch to the node user that the official image already ships with, so the process isn't root. EXPOSE is just documentation of the port. The CMD is in exec form, so node is the main process and gets the stop signal directly, and the app handles SIGTERM so it shuts down cleanly. I'd pair this with a .dockerignore that leaves out node_modules, .git and any .env files."
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Copying the whole project before installing dependencies, or leaving the container running as root without a reason.
Stages: several FROM lines in one file; each stage starts from a clean base and can be named.
Copy across: COPY --from pulls only the built output from an earlier stage.
Payoff: a much smaller final image with fewer packages to patch and no build tools for an attacker.
"A multi-stage build puts more than one FROM in the same Dockerfile. The first stage is a full build environment: compiler, package manager, source code, everything. The last stage starts again from a tiny runtime base and uses COPY --from to take only the finished artifact out of the build stage. Nothing else from the build stage ends up in the final image. For a Go service, the build stage weighs several hundred megabytes, while the final image is basically one static binary on a distroless base, often just a few tens of megabytes. That means faster pulls and deploys, and far fewer packages to show up in a vulnerability scan. It also replaces the old pattern of keeping two Dockerfiles and a shell script to glue them together."
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
Thinking a multi-stage image carries every stage's layers, or deleting build tools with RUN rm and expecting the image to shrink.
Full and slim: Debian or Ubuntu based, glibc, easy to debug; slim drops docs and extra packages.
Alpine: very small and uses musl libc, which can break prebuilt binaries or force slow native builds.
Distroless and scratch: only the runtime, no shell or package manager; smallest attack surface, hardest to debug.
"I think of it as a trade between size, security and how painful it is to debug. A full image based on Debian has everything, which is handy while building but brings hundreds of packages I'll have to patch. Slim is the same family with the extras stripped out, and it's my default for most runtime images. Alpine is tiny, but it uses musl instead of glibc, so some prebuilt binaries don't work, and for Python it can mean compiling packages from source, which makes builds slower and sometimes bigger. Distroless has only the language runtime and its libraries, no shell and no package manager, which is great for production but means I can't just exec in and poke around. Scratch is empty, which works for static Go binaries. Whatever I pick, I pin the version and rebuild regularly to pick up security fixes."
Saying Alpine is always the best choice because it's smallest, with no mention of musl or debugging trade-offs.
Layers only add: each layer records changes; a delete is stored as a marker that hides the file.
Still there: the bytes remain in the earlier layer and are pulled with the image.
Fix: create, use and delete in one RUN, use a multi-stage build, or use a build mount so the file never enters a layer.
"Image layers are stacked and each one only records changes on top of the one below. When a later step deletes a file, the new layer just adds a whiteout marker that hides it from the final view. The original bytes are still in the earlier layer, and everyone who pulls the image downloads them. So downloading a big archive in one RUN and removing it in the next saves nothing. The fix is to do the download, the use and the cleanup in a single RUN, so the file never lands in a committed layer. For build tools, a multi-stage build is cleaner, because the final stage never had them. This matters even more for secrets: a key copied in and deleted later can be pulled out of the old layer by anyone with the image. I check with docker history, which shows each layer's size."
Saying a later rm frees the space, or believing a deleted secret is safe because it isn't visible inside the running container.
Context: the folder you pass to docker build; COPY and ADD can only read files from it.
Ignore file: patterns that exclude files from the context, much like a .gitignore.
Why: faster builds, fewer cache misses and no .env files, keys or .git history copied into images.
"When I run docker build with a dot at the end, that dot is the build context: the folder the builder is allowed to read from. COPY and ADD can only pull files from inside it, which is why you can't copy something from a parent directory. A .dockerignore lists what to leave out, and I always add one. Without it, a COPY . . drags in node_modules, the .git folder, local build output and, worst of all, .env files or keys that were never meant to leave my laptop. Those end up baked into the image. It also hurts caching, because changes to files that don't matter, like a log file or the git index, change the checksum of the copy step and force a rebuild. So it's faster, the cache works better, and it keeps secrets out."
Not knowing COPY can't reach outside the context, or treating .dockerignore as a nice-to-have with no security angle.
CMD: the default command or default arguments; replaced by anything after the image name in docker run.
ENTRYPOINT: the fixed executable; only replaced with the --entrypoint flag.
Together: in exec form, CMD's values are passed as arguments to ENTRYPOINT.
"CMD sets the default. If I type anything after the image name in docker run, that replaces CMD entirely. ENTRYPOINT sets the executable that always runs, and the only way to change it is the --entrypoint flag. Used together in exec form, the JSON array style, CMD becomes default arguments to ENTRYPOINT. So if ENTRYPOINT is the app binary and CMD is --help, running the image bare prints help, and running it with serve passes serve instead. That makes the image feel like a command-line tool. A common pattern is an entrypoint shell script that does setup, like waiting for a config file, and then execs whatever CMD says. One trap: if ENTRYPOINT is written in shell form, as a plain string, CMD and any run arguments are ignored completely."
ENTRYPOINT ["/usr/local/bin/report-tool"]
CMD ["--help"]
# docker run report-tool -> report-tool --help
# docker run report-tool export csv -> report-tool export csv
Saying they are interchangeable, or not knowing that arguments after the image name replace CMD.
docker stop: sends SIGTERM, waits a grace period of ten seconds by default, then sends SIGKILL.
Cause: the shell is PID 1 and doesn't pass the signal on, or the app is PID 1 and never handles SIGTERM.
Fix: exec form, exec in entrypoint scripts, handle SIGTERM in the app, or add a tiny init with --init.
"A ten-second stop almost always means the signal isn't reaching the app. docker stop sends SIGTERM to process 1 in the container, waits ten seconds by default, and then kills it with SIGKILL. If the CMD is in shell form, process 1 is a shell running my command as a child, and the shell often doesn't pass SIGTERM along, so the app never hears it and gets killed hard at the deadline. Open connections are cut and nothing is flushed. There's a second catch: the kernel treats process 1 specially and won't apply the default action for a signal it hasn't set a handler for, so even an app running as PID 1 has to handle SIGTERM itself. The fixes are exec form, exec in any wrapper script so the app replaces the shell, and a SIGTERM handler in the code. Or I add --init, which puts a tiny init in front that forwards signals and reaps child processes."
#!/bin/sh
set -e
# setup work here, then hand over PID 1 to the real process
exec "$@"
Blaming Docker for being slow, or not knowing that shell-form commands put a shell in front of the app.
COPY: copies files and folders from the build context, nothing more.
ADD extra: unpacks a local tar archive automatically and can fetch a file from a URL.
Default: COPY, and ADD only when you want the auto-extract on purpose.
"COPY does exactly one thing: it copies files or folders from the build context into the image. ADD does that too, but it has two extra behaviours. If the source is a local tar archive, it unpacks it into the destination automatically. And it can take a URL and download the file into the image. Those extras are why I use COPY by default. With ADD, someone reading the Dockerfile has to know whether the file is an archive to work out what happens, and a URL download with ADD leaves the downloaded file sitting in its own layer, while a curl in a RUN step lets me check it, unpack it and clean up in the same layer. So COPY for normal files, ADD only when I actually want a local tarball extracted."
Saying there's no difference, or using ADD everywhere without knowing it unpacks archives.
Writable layer: anything written inside the container is deleted with docker rm.
Volume: managed by Docker, outlives the container, the normal choice for database data.
Bind mount and tmpfs: a bind mount maps a host path, good for local code and config; tmpfs lives in memory and is never written to disk.
"The data was written into the container's writable layer, and that layer is deleted when the container is removed. So the fix is to put the data directory on a volume. A named volume is storage that Docker creates and manages, separate from any container. I can remove and recreate the database container, point it at the same volume, and the data is still there. A bind mount maps a specific folder from the host into the container. I use those in development, so my code changes show up without a rebuild, and sometimes for a config file. The downside is it depends on the host's folder layout and file ownership, which often causes permission errors. tmpfs is a mount held in memory, handy for scratch files or something sensitive that should never touch disk."
Expecting data inside a container to survive docker rm, or using bind mounts to host paths for production database storage without thinking about it.
Bridge: the default; a private network on the host with outbound NAT, reached from outside through published ports.
Host: shares the host's network stack; no port mapping and no network isolation.
None and overlay: none gives only loopback; overlay spans several hosts for a cluster.
"Bridge is the default. Each container gets its own network namespace and a private IP on a virtual bridge on the host, traffic out goes through NAT, and anything coming in has to use a published port. I always create a user-defined bridge rather than use the default one, because it gives DNS by container name. Host mode drops the network isolation: the container uses the host's interfaces directly, so if the app listens on 8080 it's on the host's 8080. It saves a little overhead and helps with things that need lots of ports, but you lose isolation and can hit port clashes. None gives only a loopback interface, which suits a batch job that should have no network at all. Overlay connects containers across several hosts, which is what a Swarm cluster uses. There's also macvlan, when a container must look like its own device on the physical network."
Switching everything to host networking to fix connection problems, or not knowing the default bridge has no name-based DNS.
Cause: inside a container, localhost is the container itself, not the host or its neighbours.
Fix: put both on one user-defined network and use the other container's name as the host.
Ports: containers talk on the internal port; published host ports are only for traffic from outside.
"Every container has its own network namespace, so localhost inside the API container points at the API container itself, and nothing is listening on 5432 there. The fix is to put both containers on the same user-defined network and connect using the database container's name, so the connection string says db:5432 instead of localhost. Docker runs an internal DNS on user-defined networks that resolves that name to the container's current IP. Compose does all of this for me: every service joins a shared network and the service name works as a hostname. Two related points. Containers talk to each other on the container port, so I don't need to publish 5432 to the host at all, which is also safer. And if a container genuinely needs something running on the host, Docker Desktop provides host.docker.internal, and on Linux I can map that name to the host gateway."
Hard-coding a container's IP address, or publishing the database port to the host just so another container can reach it.
EXPOSE: records which port the app listens on; it opens nothing by itself.
-p host:container: maps a host port to a container port so traffic from outside gets in.
Gotcha: the app must listen on 0.0.0.0 inside the container, not 127.0.0.1.
"EXPOSE is basically documentation. It records in the image that the app listens on, say, port 3000, and tools can read that, but it doesn't make the port reachable from anywhere. Publishing is what does that: -p 8080:3000 tells Docker to forward port 8080 on the host to 3000 in the container. There's also -P, which publishes every exposed port to a random high port on the host. The mistake I see most often is an app that listens on 127.0.0.1 inside the container. The port is published correctly, but the app only accepts connections from inside its own namespace, so from the host you get connection refused. It needs to listen on 0.0.0.0. And if I only want the port on my own machine, I publish it as 127.0.0.1:8080:3000 so it isn't open to the whole network."
Thinking EXPOSE publishes the port, or missing the 127.0.0.1 bind problem inside the container.
Problem: replaces a pile of long docker run commands with one declared file for the whole stack.
Contents: services with their image or build, ports, environment, volumes and networks.
Commands: up to create and start everything, down to stop and remove it, logs and exec for day-to-day work.
"Without Compose, running an app with a database and a cache means three long docker run commands with the right networks, volumes and environment, typed in the right order. Compose lets me declare all of that in one YAML file that lives in the repo. Each service says either which image to use or how to build it, plus its ports, environment variables, volumes and dependencies. Compose creates a shared network automatically, so services reach each other by service name, and it creates named volumes too. Then docker compose up -d brings the whole stack up, docker compose logs follows the output, and docker compose down tears it down. The biggest win is that a new teammate clones the repo, runs one command and has the same setup as everyone else. I mostly use it for local development, CI test environments and small single-host deployments."
Describing Compose as a production cluster orchestrator, or not knowing services can reach each other by name.
Cause: plain depends_on waits for the container to start, not for the database to accept connections.
Fix: add a healthcheck to the database and use condition: service_healthy.
Still: the app should retry its connection, because databases restart in production too.
"In its short form, depends_on only controls start order. Compose starts the database container first and then the API, but a started container isn't a ready database. Postgres might still be initialising for a few seconds, so the API's first connection fails and it exits. The fix is to give the database a healthcheck, for Postgres that's pg_isready, and then use the long form of depends_on with condition: service_healthy. Now Compose holds the API back until the check passes. There's also service_completed_successfully, which is handy for a migrations job that must finish before the app starts. But I don't stop there. The app itself should retry the connection with a backoff, because in production the database can restart or fail over at any time, and a service that dies whenever the database blinks is fragile no matter how neatly it started."
services:
api:
build: .
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: devonly
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
volumes:
dbdata:
Adding a fixed sleep before starting the app, or believing depends_on waits for the database to be ready.
Never in the image: ENV and ARG values are stored in the image config or history and anyone who pulls it can read them.
Runtime: plain config through environment variables; real secrets as files mounted from a secret store.
Build time: a BuildKit secret mount for things like a private registry token.
"First rule: nothing secret goes into the Dockerfile. An ENV value is saved in the image config, and ARG values used in a RUN step show up in docker history, so anyone who can pull the image can read them. For ordinary config, like a log level or a feature flag, environment variables passed at run time are fine: -e, an env file, or the environment block in Compose. For real secrets I prefer mounting them as files, from Compose secrets, an orchestrator's secret object or a cloud secret manager, because environment variables show up in docker inspect, get inherited by child processes and end up in crash dumps and debug logs. At build time, when I need a token to pull private packages, I use a BuildKit secret mount. The file is available for that one RUN step and never written into a layer."
# Dockerfile step:
# RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=.npmrc -t api:1.4.0 .
Putting passwords in ENV or ARG, or thinking a secret is safe because it's removed in a later layer.
Facts first: docker ps -a for the exit code, docker logs for the output, docker inspect for state and the command that ran.
Common causes: the main process finished, it daemonised into the background, a missing setting crashed it, or the entrypoint path is wrong.
Explore: run the image again with a shell as the entrypoint and try the command by hand.
"A container lives only as long as its main process, so the question is why that process ended. I run docker ps -a to see the exit code, then docker logs on the container, because the answer is usually printed there. Exit code 0 means the process finished normally: the command just ran and completed, or it's a server that daemonised itself into the background, like starting a service with an init script, so the foreground process returned. A non-zero code usually means a crash, often a missing environment variable or config file. 127 means the command wasn't found, and 126 means it was found but couldn't be executed. If the logs don't make it obvious, I run the same image with --entrypoint sh and -it, and try the command by hand to see what's there. A classic one is a script saved with Windows line endings."
Adding a sleep or tail -f /dev/null to keep the container alive without finding out why it exits.
Default: no limits, so one container can starve the whole host.
Setting them: --memory and --cpus on docker run, or resource limits in Compose; they become cgroup settings.
Over the limit: too much memory gets the process killed, exit 137 with OOMKilled in inspect; too much CPU is only throttled.
"By default a container has no limits, so a memory leak in one container can take down everything on the host. I set them with --memory and --cpus, or in the Compose file, and Docker turns them into cgroup settings. Memory and CPU behave very differently when you hit them. CPU over the limit just gets throttled, so the app slows down. Memory over the limit means the kernel's out-of-memory killer kills the process, and the container exits with 137. That's 128 plus 9, meaning it died from SIGKILL. To confirm it was memory, I check docker inspect for OOMKilled being true, and the host's kernel log. 137 can also mean docker stop gave up waiting and killed it, so I don't assume. Then I check that the runtime sizes its heap against the container limit, not the host's memory, and I look for a real leak."
Thinking a CPU limit kills the container, or treating every 137 as an out-of-memory kill without checking.
Cause: the image holds binaries for one CPU architecture, for example arm64 from an ARM laptop, and the server is amd64.
Check: docker image inspect shows the image's architecture; compare it with the server.
Fix: build for the target with --platform, or build a multi-platform image with buildx and push both.
"Most of the time that's a CPU architecture mismatch. If I build on a laptop with an ARM chip, Docker builds an arm64 image by default. The server is usually x86, amd64, and the kernel can't execute arm64 binaries, so it fails with exec format error. I confirm it by inspecting the image and reading its architecture. The fix is to build for the platform I'm deploying to, with --platform linux/amd64, or better, use buildx to build for both architectures and push one multi-platform image. The registry then stores a list of images per platform, and each machine pulls the one that matches it. Building for another architecture on a laptop runs under emulation, which is slow, so in practice I let CI build on native runners. The same error can also come from a script with no shebang line, so I check that if the architecture matches."
docker image inspect --format '{{.Os}}/{{.Architecture}}' api:1.4.0
docker buildx build --platform linux/amd64,linux/arm64 \
-t registry.example.com/api:1.4.0 --push .
Blaming a corrupt image and rebuilding the same way, or not knowing images are tied to a CPU architecture.
Problem: edits in a container vanish on restart, apply to only one replica and leave no record.
First move: roll back to the last good image or change the config through the normal path.
If truly forced: do it with a witness, write it down, and follow up with a proper fix the same day.
"I'd push back, calmly, because it probably won't hold. Anything changed inside a running container is lost the moment it restarts or gets rescheduled, and if there are several replicas we'd only fix one. It also means production no longer matches any image or commit, so the next deploy quietly brings the bug back. My first move is to roll back to the last known good image tag, which is usually the fastest safe fix anyway. If it's config, I change it where config lives, the environment or the mounted config, and restart the containers properly. If none of that is possible and customers are hurting, I'd accept a manual edit as a stopgap, with a second person watching, noted in the incident channel, and a proper fix shipped through the pipeline the same day."
Editing the live container and calling it fixed, with no rollback option and no follow-up change in the image or config.
What's fine: a database in a container runs normally if its data lives on a volume on reliable storage.
What's hard: backups, restores, upgrades, performance tuning and failover are still your job.
Decide: team skill, the cost of downtime and data loss, and whether a managed database is simpler.
"I don't think containers make a database unsafe by themselves. Postgres in a container, with its data on a named volume on good storage, runs like Postgres anywhere else. What containers don't do is solve the hard parts. Someone still has to run backups and, more importantly, test restores. Someone has to plan version upgrades, tune memory against the container limit, watch disk, and handle failover if the host dies. Putting it on the same host as the app also means one bad machine takes out both, and the app can starve the database of memory unless limits are set. So I'd ask: does the team have the skill and time to operate a database, and what does an hour of downtime or a lost day of data cost us? For a small team, a managed database usually wins. Containers stay perfect for development and test databases."
A flat 'never run databases in containers' or 'it's fine, just add a volume', with no mention of backups, restores or failure of the host.
Image: small pinned base, only needed packages, multi-stage builds, no secrets in layers, rebuilt often for patches.
Runtime: a non-root user, a read-only root filesystem, dropped capabilities, no --privileged, no Docker socket mount.
Process: scan in CI, pin by version or digest, and pull only from trusted registries.
"I think about it in three layers. For the image itself: a small, pinned base, only the packages the app needs, a multi-stage build so compilers don't ship, and no secrets in any layer. I rebuild regularly, because most fixes arrive through updated base images. For the runtime: a USER instruction so the process isn't root, a read-only root filesystem with a tmpfs for the few paths that need writing, all Linux capabilities dropped and only the needed ones added back, and never --privileged. I also never mount the Docker socket into an app container, because control of that socket is effectively root on the host. For the process: an image scanner runs in CI and fails the build on serious findings with a fix available, deployments pin a version or digest, and we pull only from registries we trust."
docker run -d --name api \
--user 10001 --read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
-p 8080:3000 registry.example.com/api:1.4.0
Saying containers are secure by default because they're isolated, or running everything as root and privileged to avoid permission errors.
Registry: stores images in repositories; you tag, log in, push and pull.
Tag vs digest: a tag is a movable name; a digest is a content hash that always means the same bytes.
latest: just the default tag name, not the newest build; deploy an explicit version or a digest instead.
"A registry is a server that stores images, organised into repositories. I tag an image with the registry address, repository and a tag, log in, and push, and servers pull by the same name. A tag is just a pointer that can be moved to a different image at any time. A digest is a hash of the image content, so a given digest always means exactly the same bytes. latest isn't magic. It's simply the tag you get when you don't give one, and it doesn't mean the newest build. Deploying latest is risky because two servers can pull it at different times and run different code, and a rollback has nothing to point back to. I tag each build with the version or git commit, deploy that tag or its digest, and turn on immutable tags in the registry where I can."
Believing latest always points to the newest image, or deploying without being able to say exactly which image is running.
Triage: which findings have a fix available, and which packages the app actually uses or exposes.
Quick wins: rebuild on the patched base version, or move to a slimmer base that drops unused packages.
Decide and record: ship or hold with the security owner, and log any accepted risk with an owner and a date.
"First I'd triage instead of panicking. A long list against a base image is common, and the real questions are which ones have a fix available and which touch something the app actually uses or exposes. Often simply rebuilding on the latest patch version of the same base clears most of them, and that's a low-risk change I can test tonight. If many findings are in packages the app never needs, moving to a slim or distroless base removes them for good, but that's a bigger change I wouldn't rush the night before unless tests are strong. For anything left, I'd sit down with whoever owns security and decide together: ship with a documented, time-boxed exception, or hold the release if something is exploitable in our setup. Afterwards I'd add scheduled rebuilds so we don't find these the night before again."
Either ignoring the report because the app works, or blocking the release without checking whether any finding matters or has a fix.
Starting point: what the app was and how it used to run.
Changes: config into environment variables, logs to stdout, state moved out of the container.
Surprise and result: the thing you didn't expect, and what the move bought the team.
"At my last company I moved a Python reporting service off a hand-built server and into Docker. Writing the Dockerfile took an afternoon. Making the app fit took two weeks. It read config from a file with the database host hard-coded, so I moved every setting to environment variables. It wrote logs to a file on disk, which vanishes with the container, so I switched logging to stdout and let the platform collect it. It also saved uploaded files locally, so I moved those to object storage, which made it stateless and let us run two copies. The surprise was PDFs: on the slim base image they rendered with the wrong fonts, because the old server had fonts installed years ago that nobody remembered. I added the font package explicitly. After the move, a new environment went from a day of setup to one command."
A story where the only work was writing a Dockerfile, with no change to config, logs or state.
Symptom: what users or monitoring saw, and how bad it was.
Investigation: the evidence you gathered and the wrong turns you ruled out.
Fix and guard: the root cause fix and what stops it from coming back.
"At my last company a host running about a dozen containers started failing every few weeks. Services would crash with odd write errors, and a restart fixed it for a while. I checked the containers first and found nothing, so I looked at the host and the disk was full. Running du under Docker's data directory showed one container's log file had grown to tens of gigabytes. Docker's default json-file logging driver doesn't rotate logs unless you tell it to, and one chatty service was logging every request at debug level. I set max-size and max-file log options in the daemon config so every container got rotation by default, turned that service back to info level, and cleaned up old images and stopped containers. Then I added a disk-usage alert on every Docker host, so we'd hear about it long before a crash."
A story that ends with 'we restarted it and it went away', with no root cause and no guard added.
Baseline: the build time and image size before, and why it mattered.
Changes: each change and what it fixed: ordering, ignore file, multi-stage, CI cache.
Result: the numbers after and the effect on the team.
"At my first job we had a CI build that took around twelve minutes, even for a one-line change, and an image over a gigabyte. I looked at the build log first. Every build reinstalled all dependencies, because the Dockerfile copied the whole repo before the install step. I reordered it so the lock file went in first. Then I found the .git folder and test fixtures in the image because there was no .dockerignore, so I added one. I split the file into a build stage and a slim runtime stage, which dropped the compiler and dev tools. Last, since CI runners start empty, I set up BuildKit to store its cache in the registry. Normal builds went to about three minutes and the image to under two hundred megabytes, and deploys got noticeably quicker."
Listing tricks without any before and after numbers, or claiming a gain without knowing which change caused it.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.