Docker Build Args vs Environment and Secrets
Use build args for build-time configuration, keep secrets out of them, and understand how they interact with the cache.
Containers explained: images and layers, writing efficient Dockerfiles, multi-stage builds, volumes, networking, Compose and security.
Official reference: Docker docs
Use build args for build-time configuration, keep secrets out of them, and understand how they interact with the cache.
Speed up image builds with BuildKit cache mounts, multi-stage builds and dependency-first layer ordering.
Start only the services you need with Compose profiles, and keep the default set small for focused local development.
Ship images without a shell or package manager, copy only the runtime, and accept the debugging trade-off.
Scan images for known CVEs in CI, set a policy that blocks the build, and rebuild when base images are patched.
Why the first process in a container must reap children and forward signals, and how to avoid zombies and ignored shutdowns.
Authenticate to private registries with credential helpers, pull secrets in Kubernetes, and scoped tokens.
Choose between no, on-failure, always and unless-stopped, and combine a policy with health checks to avoid crash loops.
Store ephemeral, sensitive data in memory with tmpfs mounts, and set size limits so memory use stays bounded.
Why a Docker container exits immediately: check the exit code, read docker logs, open a shell and fix a command that only starts a background job.
Fix 'exec format error' in Docker: confirm the CPU architecture mismatch with uname and docker inspect, then rebuild with the right --platform or buildx target.
Fix Docker 'no space left on device' during build or pull: measure with docker system df, prune in a safe order, and cap logs and build cache.
Fix 'permission denied while trying to connect to the Docker daemon socket': docker group, re-login, rootless mode, contexts and a dead daemon.
Fix Docker 'port is already allocated' and 'address already in use': find the container or process on the port, then remap or bind to 127.0.0.1.
How one tag serves amd64 and arm64, why native modules break across CPUs, and how to build multi-platform images by emulating or cross-compiling.
Bind mounts map a host path into a container: great for live code, risky for ownership, portability and security. See where each one bites.
Plain depends_on only orders container starts. Use healthchecks, service_healthy and service_completed_successfully to wait for a ready database.
How ENTRYPOINT and CMD combine into one argv, what docker run arguments replace, and why appending sh to some images never gives you a shell.
How a HEALTHCHECK moves from starting to unhealthy, why plain Docker never restarts an unhealthy container, and what a good probe checks.
What the build context is, why COPY . . can ship your .env and .git, and how to write .dockerignore patterns that match nested folders.
An image is a read-only template; a container is that template plus a writable layer and a process. See what survives stop, start and rm.
An image is frozen the day it's built. Keep bases patched with scheduled rebuilds, --pull, digest pins with automated bumps and EOL tracking.
Tags are movable pointers and digests are not. Learn why latest breaks rollbacks, how to tag releases, and when to pin images by sha256.
Why one edited source file can rerun npm install: how BuildKit's layer cache keys each step, and how to order a Dockerfile so rebuilds stay fast.
docker logs only shows stdout and stderr. Fix empty logs, Python buffering and unrotated json-file growth, then pick a logging driver.
Compile in one stage, ship only the output in the next: how a 400 MB build image became a 313 kB runtime, and what a scratch image gives up.
Why containers can't resolve each other on the default bridge, how the 127.0.0.11 resolver works, and why nginx keeps a stale upstream IP.
Switching a container to a nonroot USER breaks writes until ownership is right. Fix EACCES with chown, numeric UIDs and pre-created volume paths.
EXPOSE documents a port; -p opens one on the host. Bind addresses, -P, the 127.0.0.1 trap inside containers and the ufw bypass on Linux.
Run a container with --read-only, find the paths it still needs to write, and give each one a sized tmpfs or volume without reopening the rest.
Memory limits end in a silent OOM kill and CPU limits in throttling. Size a container so the runtime inside it agrees with the cgroup outside.
Build args and ENV values stay readable in docker history and image config. Pass build-time credentials with BuildKit secret mounts instead.
Why docker stop sometimes takes the full timeout and ends in exit 137, and how PID 1, exec form and a SIGTERM handler fix shutdown.
Named and anonymous volumes outlive containers in different ways. Trace which commands keep data, which delete it, and how to back it up.
Docker is a platform for building, shipping and running applications in containers. You package the app together with its runtime, libraries and config into an image, and that image runs the same way on a laptop, a CI runner or a production server.
The main problem it solves is environment drift: "works on my machine" failures caused by different OS packages, language versions or config. It also gives you:
Under the hood, Docker is a client (the docker CLI), a daemon (dockerd) and lower-level runtimes (containerd and runc) that use Linux namespaces and cgroups.
Likely follow-up: What is the difference between Docker Engine and Docker Desktop?
A virtual machine virtualises hardware. A hypervisor runs full guest operating systems, each with its own kernel, so a VM carries a whole OS, takes longer to boot and needs more memory.
A container virtualises the operating system. All containers on a host share the host's kernel. Each one is just a process, or a group of processes, isolated with Linux namespaces (what it can see) and limited with cgroups (what it can use). There's no guest kernel, so containers start quickly, have little overhead, and you can pack far more of them onto a host.
The trade-offs:
Likely follow-up: How does Docker Desktop run Linux containers on a Mac? · What problem do gVisor or Kata Containers solve?
An image is a read-only template: a stack of filesystem layers plus metadata such as the default command, environment variables, exposed ports and working directory. It's identified by a content digest and usually referenced by a tag like nginx:alpine.
A container is a running (or stopped) instance of an image. When you create one, Docker adds a thin writable layer on top of the image's read-only layers and gives the process its own namespaces, network interface and configuration.
The usual analogy is class versus object: one image, many containers. Each container's changes go into its own writable layer, so they don't affect the image or other containers, and they're gone when the container is removed. That's why data you need to keep belongs in a volume, and why you change an image by rebuilding it, not by editing a running container.
Likely follow-up: What does docker commit do, and why is it discouraged?
A Dockerfile is a recipe the builder runs top to bottom. Most instructions either add a layer or change the image's config:
FROM picks the base image, here Node 22 on slim DebianWORKDIR sets, and creates if needed, the directory later instructions and the container useCOPY brings in only the dependency manifests, so the npm ci layer stays cached until dependencies changeRUN executes a command at build time and saves the result as a layerCOPY adds the source codeENV sets an environment variable for later steps and for the running containerEXPOSE documents the port the app listens on; it doesn't publish itUSER switches to the non-root node user the official image providesCMD is the default command at run time, in exec form so node runs as PID 1 and receives signalsBuild it with docker build -t my-api . and run it with docker run -p 3000:3000 my-api.
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]-p actually publishesLikely follow-up: Why use npm ci rather than npm install in a Dockerfile?
CMD and ENTRYPOINT? What does docker run my-image there print for this Dockerfile?midENTRYPOINT defines the executable the container always runs; CMD supplies default arguments, or the default command when there's no entrypoint. In exec form, Docker runs ENTRYPOINT followed by CMD.
docker run my-image prints hello worlddocker run my-image there prints hello there: arguments after the image name replace CMD, not the entrypointdocker run --entrypoint date my-image replaces the entrypoint and also clears the image's default CMDShell form is different: ENTRYPOINT echo hello is wrapped in /bin/sh -c and ignores both CMD and any run arguments, so it would print just hello.
A common pattern is ENTRYPOINT ["/docker-entrypoint.sh"] with CMD ["postgres"]: the script does setup, then runs exec "$@" so the real process replaces the shell. Use CMD alone when users should freely swap the command, and ENTRYPOINT when the image behaves like a tool.
FROM alpine:3.20
ENTRYPOINT ["echo", "hello"]
CMD ["world"]docker run arguments replace CMD only--entrypoint overrides the entrypoint and clears CMDLikely follow-up: Why do entrypoint scripts usually end with exec "$@"?
COPY and ADD in a Dockerfile, and which should you use?easyBoth copy files into the image, but ADD has extra behaviours:
--unpack to control this--checksum to verify a remote sourceCOPY simply copies files from the build context, or with --from, from another build stage or image. Docker's guidance is to prefer COPY because it's explicit and predictable; the auto-extraction in ADD can surprise whoever reads the Dockerfile later. Reach for ADD only when you actually want one of its features, typically unpacking a local tarball.
Both support --chown and --chmod, which set ownership and permissions as the files are copied. That's better than a later RUN chown -R, which would duplicate every file into a new layer.
Likely follow-up: Why does RUN chown -R after a COPY increase image size?
An image is an ordered stack of read-only layers. Instructions that change the filesystem, mainly RUN, COPY and ADD, each produce a layer holding just the files they added, changed or deleted. Others, like ENV or CMD, only change metadata. Layers are content-addressed, so identical layers are stored once and shared between images, and only missing layers are pulled or pushed.
When building, the builder walks the instructions in order and looks for a cached result:
COPY and ADD, the checksums of the source files are part of the key, so editing a copied file invalidates itRUN is matched on its command string, not its output: RUN apt-get update won't re-run just because the upstream repository changedThe key rule: once one step misses the cache, every step after it is rebuilt. So you order instructions from least to most frequently changing, and use --no-cache or --pull when you need fresh results.
Likely follow-up: Why is RUN apt-get update on its own line an anti-pattern?
A multi-stage build has several FROM instructions in one Dockerfile. Each starts a new stage, and you COPY --from=<stage> only the artifacts you need into the final one. Compilers, dev dependencies, source code and build caches stay behind in earlier stages and never ship.
Benefits:
docker build --target build . stops at a named stage, which is handy for running tests in CI from the same Dockerfile. For compiled languages the win is dramatic: build a Go binary with the full toolchain, then copy just that binary into a scratch or distroless image.
FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]COPY --from takes only the needed artifacts--target builds up to a specific stageLikely follow-up: How would you run unit tests as part of a multi-stage build?
All three keep data out of the container's writable layer, but the data lives in different places:
/var/lib/docker/volumes on Linux). They survive container removal, behave the same on every OS, can use drivers for remote storage, and an empty volume is pre-populated with whatever the image has at that path. They're the default choice for persistent data like databases.One gotcha: with -v, a missing host path is silently created as a directory, while --mount type=bind fails with an error.
# Named volume: managed by Docker, survives container removal
docker run -d -e POSTGRES_PASSWORD=dev \
-v pgdata:/var/lib/postgresql/data postgres:16-alpine
# Bind mount: a host directory, e.g. live code in development
docker run --rm -it --mount type=bind,src="$(pwd)",dst=/app -w /app node:22 npm test
# tmpfs: memory only, discarded when the container stops
docker run --rm --tmpfs /scratch:size=64m alpine:3.20 df -h /scratchLikely follow-up: How would you back up a named volume?
COPY . . copies the whole build context, and its cache key includes the checksum of every file. Change one line of source and that step misses the cache, so RUN npm ci after it, and every later step, runs again.
The fix is to order instructions from least to most frequently changing and copy only what each step needs:
COPY package.json package-lock.json ./RUN npm ciCOPY . .Now the install layer is only invalidated when the manifests change; a code edit rebuilds just the final COPY. The same idea applies everywhere: requirements.txt before the Python source, go.mod and go.sum before go mod download.
Two more things help: a .dockerignore so node_modules, .git and logs don't invalidate the cache or bloat the context, and a BuildKit cache mount for the package manager's cache, so even a dependency change doesn't download everything from scratch.
FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]Likely follow-up: How would you share the build cache between CI runs?
Docker Compose lets you define a multi-container application in one YAML file, usually compose.yaml, and manage it as a unit. Instead of a pile of docker run flags, you declare services (each becomes one or more containers) plus the networks and volumes they use.
Key behaviours:
docker compose up -d builds or pulls images, creates a project network and starts everything; docker compose down removes the containers and network, and down -v deletes the volumes tooapi reaches the database at the hostname dbdepends_on controls start order, and with a health-check condition it can wait for readiness${TAG} are interpolated from the shell environment or a .env fileIt's ideal for local development, integration tests in CI and small single-host deployments. For multi-host scheduling, self-healing and rolling updates, you'd move to Kubernetes or a managed orchestrator.
services:
api:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://postgres:dev@db:5432/postgres
depends_on: [db]
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: dev
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:up -d starts the stack, down removes itdepends_on controls start orderLikely follow-up: What is the difference between docker compose and the old docker-compose?
I'd work through these, roughly in order of impact:
-slim Debian variant, Alpine or distroless instead of the full default tag.dockerignore so .git, node_modules, test data and logs never get copied inRUN that made the mess, e.g. apt-get install --no-install-recommends ... && rm -rf /var/lib/apt/lists/*. Deleting files in a later instruction doesn't shrink the image, because the earlier layer still contains themRUN chown -R over large trees, which copies every file into a new layer; use COPY --chown insteadTo find the culprit, docker history <image> shows how much each instruction added, and tools like dive let you browse layer contents. Smaller images pull and start faster and have fewer packages to patch.
docker history shows size per instructionLikely follow-up: Why does RUN rm big-file as a separate step not reduce the image size?
The main drivers:
docker network create) also gives DNS between containers-p is ignored with a warning), but no NAT overhead. Mainly a Linux feature; Docker Desktop supports it only from 4.34 as an opt-in--attachableThe distinction interviewers usually want is bridge versus host: bridge gives isolation and port mapping, host trades isolation for simplicity and raw network performance.
-p ignoredLikely follow-up: Why might you run a high-throughput proxy with host networking?
Put both containers on the same user-defined network. For user-defined networks, Docker runs an embedded DNS server, visible as 127.0.0.11 inside the container, so containers resolve each other by container name, by any --network-alias, and in Compose by service name.
The default bridge network, which containers join when you don't pass --network, doesn't offer that DNS. Containers on it can only reach each other by IP address, unless you use the legacy --link flag. Docker's docs treat the default bridge as a legacy detail and recommend user-defined networks, which also isolate stacks from each other instead of putting every container on one shared network.
Two related points: containers talk to each other on the container port (web:80), not on a published host port, and you don't need -p at all for container-to-container traffic. Publishing is only for reaching a container from outside Docker's networks.
docker network create app-net
docker run -d --name web --network app-net nginx:alpine
docker run --rm --network app-net alpine:3.20 wget -qO- http://web-p needed between containersLikely follow-up: How can one container be attached to two networks at once?
EXPOSE in a Dockerfile and -p on docker run?easyEXPOSE 80 is documentation: it records in the image metadata which port the app listens on. It doesn't open or publish anything.
Publishing happens at run time:
-p 8080:80 maps host port 8080 to container port 80-p 127.0.0.1:8080:80 binds only on the host's loopback, so other machines can't reach it-P publishes every exposed port to random high host ports, which is the one place EXPOSE has a real effectBy default, -p binds on all host interfaces, which the Docker docs call "insecure by default": the service is reachable from the network, and on Linux Docker's NAT rules divert traffic before firewalls like ufw see it. For local-only services, such as a dev database, bind to 127.0.0.1.
Also make sure the app inside listens on 0.0.0.0, not 127.0.0.1; otherwise the port is published but nothing answers.
-p host:container publishes a port-P publishes all exposed ports to random portsLikely follow-up: Why does traffic to a published port bypass ufw rules?
ARG and ENV in a Dockerfile?midBoth define variables, but they live for different amounts of time:
ARG is a build-time variable. You set it with docker build --build-arg APP_VERSION=1.2, later instructions in that stage can use it, and it's not in the running container's environmentENV is stored in the image config, so it's available to later build steps and to every container at run time, where docker run -e can override itScope gotchas: an ARG declared before FROM can only be used in FROM lines; redeclare it inside the stage to use it there. Each stage of a multi-stage build needs its own ARG declarations.
Neither is safe for secrets. ENV values are baked into the image config, and build-arg values appear in docker history. Use a BuildKit secret mount for build-time secrets and inject runtime secrets when the container starts.
The snippet shows the common trick of copying an ARG into an ENV to keep a build-time value at run time.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-bookworm-slim
ARG APP_VERSION=dev
ENV NODE_ENV=production \
APP_VERSION=${APP_VERSION}
RUN echo "building $APP_VERSION"--build-argdocker historyLikely follow-up: How can you pass a private npm token to a build without leaking it?
.dockerignore file?easyIn docker build ., the . is the build context: the files the builder can read for COPY and ADD. A .dockerignore file in the root of the context lists patterns to exclude, much like .gitignore.
Why it matters:
COPY . . isn't invalidated by changes to .git, logs or local build outputnode_modules, possibly compiled for another OS, doesn't overwrite the one installed in the image.env files, keys and credentials don't end up in a layer by accidentA line starting with ! re-includes a file, and the last matching line wins. You can also give a specific Dockerfile its own ignore file, such as build.Dockerfile.dockerignore next to build.Dockerfile, which takes precedence over the one at the root of the context.
! re-includes; the last matching rule winsLikely follow-up: Does .dockerignore affect files mounted as volumes at run time?
I work from the outside in:
docker ps -a shows the state and exit code, such as Exited (1) or Restartingdocker logs shows what the process wrote to stdout and stderr, and it works on stopped containers. Most crashes are explained here: a missing env var, bad config, an unreachable databasedocker inspect gives State.ExitCode, State.OOMKilled, State.Error and the restart count, plus the exact command, env and mounts the container gotexec into, override the entrypoint with a shell and run the start command by handdocker cp copies files out of a stopped container, and docker events shows the start, die and oom sequenceTypical root causes: the main process exits because it daemonised or had nothing to do, permission errors after switching to a non-root user, or exec format error from an image built for a different CPU architecture.
docker ps -a --filter name=api # status and exit code
docker logs --tail 100 api # output from the last run
docker inspect api --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.RestartCount}}'
docker events --filter container=api
docker run -it --rm --entrypoint sh my-image # reproduce by hand
docker cp api:/app/logs ./logs # works on stopped containersdocker ps -a for state and exit codedocker logs works on stopped containersdocker inspect for OOMKilled, error and configLikely follow-up: How do you debug a distroless container that has no shell?
docker stop, docker kill and docker rm differ?easyA container moves through a few states: created (docker create: config and writable layer exist, nothing running), running (docker start, or docker run, which is create plus start), paused (docker pause freezes its processes using the cgroup freezer), exited (the main process ended or was stopped) and finally removed.
The commands differ in how they end it:
docker stop sends the stop signal (SIGTERM unless the image sets STOPSIGNAL), waits a grace period, 10 seconds by default for Linux containers, then sends SIGKILL. That gives the app a chance to shut down cleanlydocker kill sends SIGKILL straight away by default, or any signal you choose with -sdocker rm deletes a stopped container and its writable layer; -f kills and removes a running oneA stopped container still exists, keeps its writable layer and logs, and can be started again. Use docker run --rm for throwaway containers so they're removed when they exit.
docker run is create plus startLikely follow-up: How do you give a slow-shutdown app more time to stop?
always and unless-stopped differ?easyA restart policy tells the Docker daemon what to do when a container's main process exits:
no (default): never restart automaticallyon-failure[:max-retries]: restart only after a non-zero exit code, optionally capped, e.g. on-failure:5always: restart whenever it stops, and start it again when the daemon starts. If you stop it manually, it stays stopped until the daemon restarts or you start it yourselfunless-stopped: like always, except a container you stopped manually stays stopped even after a daemon restartSet it with docker run --restart unless-stopped, change it on an existing container with docker update --restart, or use restart: in Compose. The daemon doubles the delay between attempts, starting at 100 ms, so a crash loop doesn't hammer the host.
Restart policies are single-host crash recovery, not orchestration: they don't move containers to another machine, and a failing health check alone doesn't trigger a restart. Swarm and Kubernetes add that.
Likely follow-up: How would you make a container restart when it becomes unhealthy?
depends_on fix that?midPlain depends_on: [db] only controls start order. Compose starts the db container first, but it doesn't wait for Postgres to accept connections, so the API can start, fail to connect and exit.
The long syntax adds a condition:
service_started: the default, same as the short formservice_healthy: wait until the dependency's health check passes. The dependency needs a healthcheck, defined in Compose or in its imageservice_completed_successfully: wait for a one-off job, like a migration container, to exit with code 0With service_healthy, docker compose up shows the database as Waiting, then Healthy, and only then starts the API.
Even so, the app should still retry connections with back-off. depends_on only matters at startup: if the database restarts later, or you deploy to Kubernetes where there's no depends_on, resilient connection handling is what keeps the service up.
services:
api:
build: .
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10service_healthy waits for the health checkservice_completed_successfully for one-off jobsLikely follow-up: How would you run database migrations before the API starts?
HEALTHCHECK instruction do, and what happens when a container becomes unhealthy?midHEALTHCHECK tells Docker how to test whether the app is actually working, not just whether its process is alive. Docker runs the command inside the container on a schedule: exit code 0 means healthy, 1 means unhealthy.
The status starts as starting, becomes healthy after a passing check, and turns unhealthy after --retries consecutive failures. The defaults are a 30s interval, 30s timeout, 0s start period and 3 retries. Failures during --start-period don't count, which gives slow apps time to boot. docker ps shows the status and docker inspect shows recent probe output.
It's used by Compose's condition: service_healthy, by Swarm to replace unhealthy tasks, and by monitoring. But standalone Docker doesn't restart an unhealthy container by itself.
Keep the check cheap, make sure the tool it calls (curl, wget) exists in the image, and remember that Kubernetes ignores HEALTHCHECK and uses its own liveness and readiness probes.
FROM nginx:alpine
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1--start-period gives slow apps a grace periodLikely follow-up: How would you health-check a distroless image with no shell or curl?
By default the process in a container runs as root (UID 0). It's namespaced and has a reduced set of capabilities, but without user namespaces it's still UID 0 on the shared kernel. If an attacker escapes through a kernel or runtime bug, or through a dangerous mount like the Docker socket, they're root on the host. Even without an escape, root inside lets a compromised app rewrite its own files and install tools.
To run as non-root:
node in the official Node imagesUSER, ideally by numeric UID, so Kubernetes' runAsNonRoot check can verify itCOPY --chown only for paths the app must write; keep code root-owned and read-onlydocker run --user 10001:10001The usual pain point is bind mounts and volumes: the container's UID needs permission on the mounted files, so align UIDs or fix ownership deliberately.
FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app -u 10001 app
WORKDIR /app
COPY --chown=app:app ./bin/server /app/server
USER 10001
CMD ["/app/server"]Likely follow-up: What is the difference between running as non-root and rootless mode?
A container isn't a kernel object. It's an ordinary process that the runtime starts with two Linux features applied.
Namespaces control what the process can see:
userns-remapControl groups (cgroups) control what it can use: memory, CPU time, block I/O and the number of processes, with the accounting that docker stats reads.
On top of that, Docker drops most Linux capabilities, applies a default seccomp profile that blocks risky system calls, and can apply AppArmor or SELinux policies. All of it relies on one shared kernel, so a kernel vulnerability can undermine every layer.
Likely follow-up: Which capabilities does Docker keep by default, and why is --privileged dangerous?
By default a container has no resource limits: it can use as much memory and CPU as the host's kernel scheduler allows, so one leaky service can starve everything else. Docker enforces limits with cgroups:
--memory (-m) is a hard limit. Go over it and the kernel's OOM killer kills a process in the container, usually the main one, giving exit code 137 and OOMKilled: true--memory-swap is memory plus swap; setting it equal to --memory means no swap--cpus 1.5 caps CPU time at one and a half CPUs' worth. Going over means throttling, not killing--cpu-shares is a relative weight that only matters when CPUs are contended--pids-limit caps the number of processes, which stops fork bombsIn Compose, set them under deploy.resources.limits. Make sure the runtime respects the limit: modern JVMs size their heap from the cgroup limit, but other runtimes may need explicit heap flags. Use docker stats to size limits from real usage.
--memory-swap equal to --memory disables swapdocker statsLikely follow-up: Why might a Java app get OOM-killed even though its heap is below the limit?
docker stop sometimes hang for 10 seconds and then kill the app? What is special about PID 1 in a container?hardThe first process in a container is PID 1 in its PID namespace, and the kernel treats PID 1 specially: signals whose default action is to terminate, like SIGTERM and SIGINT, are ignored unless the process installs a handler. PID 1 also adopts orphaned processes and is expected to reap zombies.
docker stop sends SIGTERM, waits a grace period (10 seconds by default), then sends SIGKILL. If PID 1 ignores SIGTERM, you get a slow stop, exit code 137 and no clean shutdown. Common causes:
CMD or ENTRYPOINT, where /bin/sh -c is PID 1 and doesn't forward the signalexecSIGTERM handlerFixes: use exec form, end entrypoint scripts with exec "$@", handle SIGTERM in the app (stop accepting work, drain requests, close connections, exit), and add a minimal init like tini with docker run --init. The init runs as PID 1, forwards signals to your app and reaps zombies.
# Problem: shell form, so sh is PID 1 and node never gets SIGTERM
CMD node server.js && echo done
# Fix 1: exec form, node is PID 1 and must handle SIGTERM
CMD ["node", "server.js"]
# Fix 2: a tiny init as PID 1 that forwards signals and reaps zombies
# docker run --init my-image (Compose: init: true)exec "$@"--init runs tini to forward signals and reap zombiesLikely follow-up: What is a zombie process, and why might they pile up in a container?
RUN, CMD and ENTRYPOINT?midShell form is a plain string, like CMD node server.js. Docker runs it as /bin/sh -c "node server.js", so you get shell features: variable expansion, &&, pipes and redirects.
Exec form is a JSON array, like CMD ["node", "server.js"], and Docker executes the binary directly, without a shell.
Why it matters:
SIGTERM. Exec form makes your app PID 1CMD ["echo", "$HOME"] prints the literal $HOME. Use ["sh", "-c", "..."] when you need expansionCMD and docker run arguments/bin/sh, which scratch and distroless images lackRule of thumb: shell form for RUN, exec form for CMD and ENTRYPOINT.
# Shell form: runs via /bin/sh -c, so variables and && work
RUN apt-get update && apt-get install -y curl
CMD echo "Hello $NAME"
# Exec form: a JSON array, executed directly with no shell
CMD ["node", "server.js"]
CMD ["sh", "-c", "echo Hello $NAME"] # explicit shell when you need one/bin/sh -cLikely follow-up: How would you use an environment variable in an exec-form CMD?
A registry stores and distributes images, organised into repositories that hold tagged versions. Docker Hub is the default, and common alternatives are Amazon ECR, Google Artifact Registry, Azure Container Registry, GitHub Container Registry and self-hosted Harbor.
An image reference looks like registry/namespace/repository:tag. Missing parts get defaults, so nginx:alpine really means docker.io/library/nginx:alpine.
The workflow:
docker login authenticates to the registrydocker tag gives the local image a name that includes the target registrydocker push uploads only the layers the registry doesn't already havedocker pull downloads only the layers you're missingRegistries implement the OCI Distribution API, so any compliant tool can push and pull. In production, teams use a private registry close to their cluster, turn on vulnerability scanning there, and mirror or cache public base images to avoid rate limits and outages.
Likely follow-up: How would a CI pipeline authenticate to a registry without a long-lived password?
Environment variables are fine for configuration like log levels, feature flags and hostnames, but they're a poor home for secrets:
docker inspect on the container can read themENV in a Dockerfile are baked into the image for anyone who pulls itA secret is delivered as a file instead. In Compose and Swarm, a granted secret is mounted at /run/secrets/<name> in that service's containers and isn't stored in the image. Many official images support a _FILE convention, such as POSTGRES_PASSWORD_FILE, so the value is read from that file.
In production, secrets usually come from a manager like Vault, AWS Secrets Manager or Kubernetes Secrets and are injected at run time. For build-time secrets, such as a private package token, use BuildKit's RUN --mount=type=secret so the value never lands in a layer.
/run/secrets_FILE convention in many official imagesLikely follow-up: How are Swarm secrets stored and delivered differently from Compose file secrets?
-slim, Alpine and distroless base images?midThe base image is usually the biggest factor in both size and attack surface:
node:22 or python:3.12: compilers and common libraries included. Easiest to work with, but large and with many packages to patch. Good for build stages-slim variants: the same distro and glibc, minus most extras. Usually the best default for runtime stages, because almost everything still just works:debug variants with a BusyBox shellscratch: completely empty, ideal for static Go or Rust binariesMy default is a full image to build, slim or distroless to run, and Alpine only once I've confirmed the app works on musl.
scratch for static binariesLikely follow-up: Why can a Python image take much longer to build on Alpine?
docker run -d -p 8080:80 nginx?midDocker is client-server. The docker CLI sends REST API calls to the Docker daemon, dockerd, usually over the Unix socket /var/run/docker.sock. Then:
nginx:latest locally and, if it's missing, pulls it: resolves the tag to the manifest for the host's platform and downloads the missing layersstdout and stderr go to the logging driver, and since we passed -d, the CLI just prints the container ID.
Likely follow-up: How can containers keep running while dockerd restarts?
docker exec and docker attach?easydocker exec starts a new process inside an already running container, sharing its namespaces, filesystem and network. docker exec -it api sh is the standard way to get a shell for debugging, and exiting that shell doesn't affect the main process. It only works on running containers.
docker attach connects your terminal to the main process's stdin, stdout and stderr. No new process is started: you see the app's output, and what you type goes to it. The catch is that Ctrl+C is forwarded to the main process and can stop the container. To detach from a container started with -it, press Ctrl+P then Ctrl+Q.
docker run is different again: it creates a new container from an image.
In practice: exec to investigate, logs -f to watch output, and attach only for interactive programs like a REPL or console that expects input.
Likely follow-up: How do you get a shell in a container that has already crashed?
Docker captures whatever the main process writes to stdout and stderr, which is why containerised apps should log to the console rather than to files. docker logs reads it back, with -f to follow, --tail, --since and -t for timestamps.
Where logs end up depends on the logging driver:
json-file is the default. It writes JSON lines on the host and does no rotation by default, so a chatty container can fill the disk. Set the max-size and max-file options, or switch to the local driver, which Docker recommends because it rotates by default and uses a more compact formatsyslog, journald, fluentd, awslogs, gcplogs, splunk and moredocker logs still worksSet the default in daemon.json, or per container with --log-driver and --log-opt. In production, most teams ship logs to a central system like ELK, Loki or CloudWatch instead of reading them on hosts.
docker logs -f, --tail, --sinceLikely follow-up: Why does changing the logging driver in daemon.json not affect existing containers?
Docker doesn't clean up unused objects on its own, so stopped containers, old images, build cache and volumes pile up. Start with docker system df, or -v for detail, to see which category is the problem.
Then use the matching prune command:
docker container prune removes stopped containersdocker image prune removes only dangling images, the untagged <none> images left behind when a tag moves to a new build. Add -a to remove every image not used by a containerdocker builder prune clears the build cache, often the biggest item on CI machinesdocker system prune does containers, unused networks, dangling images and build cache in one goVolumes are never removed by default, because they hold data. docker volume prune removes unused anonymous volumes, and -a includes named ones, so check before running it.
On CI, prune with --filter until=24h to keep recent cache, and fix the causes: run containers with --rm, rotate logs and limit the builder's cache size.
docker system df # images, containers, volumes, build cache
docker container prune # stopped containers
docker image prune # dangling images only
docker image prune -a # every image not used by a container
docker builder prune # build cache
docker system prune # containers, networks, dangling images, build cache
docker volume prune # unused anonymous volumes (-a for named ones too)docker system df shows usage by type-a all unuseddocker builder prune for build cacheLikely follow-up: What is a dangling image, and how is it created?
docker run flags: -d, -it, --rm, --name, -e, -p and -v.easy-d (detached) runs the container in the background and prints its ID. Without it, your terminal is attached to the container's output-i keeps stdin open and -t allocates a pseudo-terminal. Together, -it gives you an interactive shell--rm removes the container when it exits, perfect for one-off commands so stopped containers don't pile up--name sets a fixed, readable name instead of a random one. Names must be unique, and on user-defined networks they double as DNS names-e KEY=value sets an environment variable, and --env-file loads many from a file-p host:container publishes a container port on the host-v source:target mounts a named volume or host path; --mount is the more explicit equivalentAnything after the image name replaces the image's CMD, so docker run --rm nginx:alpine nginx -v prints the nginx version instead of starting the server.
-d runs in the background-it for an interactive terminal--rm removes the container on exit-e, -p, -v for env, ports and storageLikely follow-up: What happens if you run docker run -d ubuntu with no command?
I think about it in two halves.
The image:
The runtime, following least privilege:
--cap-drop ALL and add back only what's needed--read-only root filesystem, with tmpfs for scratch paths--security-opt no-new-privileges to block privilege escalation through setuid binaries--privileged or mount /var/run/docker.sock casuallyFor another layer of isolation, run the daemon in rootless mode or enable user namespace remapping.
docker run -d --name api \
--user 10001:10001 \
--read-only --tmpfs /tmp \
--cap-drop ALL --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges \
--memory 512m --pids-limit 200 \
my-api:1.4.0--privileged and the Docker socketLikely follow-up: A scanner reports 200 CVEs in your image. How do you triage them?
Codes above 128 mean the process was killed by a signal: the code is 128 plus the signal number.
docker logsdocker run command itself failed, for example because of an invalid flag, so no container process randocker inspect shows OOMKilled: true), docker kill, or docker stop giving up after its grace perioddocker stop sends. An app that handles SIGTERM itself usually exits with 0 insteadSo 137 sends me straight to memory limits and to whether the app handles SIGTERM quickly enough.
Likely follow-up: How do you confirm that a 137 was caused by the OOM killer?
curl localhost:8080 on the host gets "connection refused". What do you check?easyI'd go through these in order:
docker ps or docker port <name> should show something like 0.0.0.0:8080->3000/tcp. EXPOSE alone doesn't publish; you need -p 8080:3000-p HOST:CONTAINER. Mapping to a container port the app doesn't listen on gives exactly this symptom127.0.0.1 inside the container, it only accepts connections from inside that container's network namespace. Published traffic arrives on the container's network interface, so the app must listen on 0.0.0.0. Many dev servers default to localhost and need a --host 0.0.0.0 flagdocker logs, and test from inside with docker execAnd remember: localhost inside a container means the container itself, not the host.
-pdocker execLikely follow-up: How would a container reach a database running on the host machine?
The guideline is one concern per container. Usually that's one main process, though a process that forks its own workers, like nginx or gunicorn, is fine.
Reasons to split them:
docker stop shuts down one thing properlyRun the pieces as separate containers and connect them with a network, Compose or a Kubernetes pod, where tightly coupled helpers run as sidecars.
When you genuinely need several processes, for example to wrap a legacy app, use a proper init or supervisor, such as tini with a small script or supervisord, so signals are forwarded and zombies reaped. Treat it as the exception.
Likely follow-up: What is a sidecar container, and when would you use one?
docker save and docker export?midThey work on different objects and produce different archives:
docker save takes one or more images and writes a tar with all their layers, config and tags. docker load restores them exactly, with history, CMD, ENV and tags intact. It's the way to move images to an air-gapped machine or cache them without a registrydocker export takes a container and writes its flattened filesystem: the image layers plus the container's changes, merged into one tree, with no layers, history or metadata. docker import turns that tar into a new single-layer image with no CMD, ENV or entrypoint unless you add them with --changeNeither includes volume contents; volume data lives outside the container's filesystem.
So: save and load to transport images faithfully, export and import to snapshot or flatten a filesystem, which is rarely what you want for deployments. Pushing to a registry is usually better than either.
Likely follow-up: How would you move images to a server with no internet access?
Both build and run OCI containers with a nearly identical CLI: podman run takes the same flags as docker run, and images move freely between them. The differences are architectural:
dockerd, which traditionally runs as root. Podman is daemonless: each podman command launches the container itself, with a small monitor process, conmon, per containerpodman compose, and Buildah for buildsI'd pick Docker for developer experience and tooling, and Podman where daemonless, rootless operation or Red Hat ecosystem integration matters.
Likely follow-up: Can you run a Compose file with Podman?
The Open Container Initiative (OCI) publishes open standards so tools can interoperate:
config.jsonDocker's stack is layered on top of these:
dockerd provides the Docker API, BuildKit builds, networking and volumesThat's why Kubernetes could remove dockershim in v1.24: kubelet talks to containerd or CRI-O through the CRI, and Docker-built images still run because they're OCI images.
Likely follow-up: What changed for Docker users when Kubernetes removed dockershim?
ARG NPM_TOKEN a bad idea, and how should you pass it instead?hardBuild args and ENV values are recorded in the image: build-arg values show up in docker history, and ENV is stored in the image config. Copying an .npmrc in and deleting it later doesn't help either, because the earlier layer still contains the file. Anyone who can pull the image can recover the token.
BuildKit secret mounts fix this. The secret is mounted as a file for a single RUN step, at /run/secrets/<id> by default or wherever target points, and it's never written to a layer or the build cache. You supply it at build time:
docker build --secret id=npmrc,src=$HOME/.npmrc .--secret id=npm_token,env=NPM_TOKENIn the Dockerfile, RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN exposes it as an environment variable for that one command instead of a file.
For Git over SSH, use RUN --mount=type=ssh with docker build --ssh default, which forwards your SSH agent instead of copying keys. Compose supports the same through build.secrets.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .
# docker build --secret id=npmrc,src=$HOME/.npmrc .RUN --mount=type=secret exposes it to one step onlydocker build --secret--mount=type=ssh for SSH keysLikely follow-up: How would you verify that a secret did not end up in the final image?
The layer cache is all or nothing: if package-lock.json changes, the npm ci layer is rebuilt from scratch and every package is downloaded again.
A cache mount gives a RUN step a persistent directory that survives between builds but is not part of the image. Point it at the package manager's cache, like /root/.npm, /root/.cache/pip, /go/pkg/mod or /root/.m2, and a rebuild only downloads what changed.
Key properties:
sharing option (shared, private or locked) controls concurrent builds; locked is common for apt, which can't handle parallel accessdocker builder prune clears it, and it isn't included when you export layer cache to a registry, which limits its value on ephemeral CI runnersCache mounts are a BuildKit feature, and BuildKit is the default builder in current Docker releases.
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .sharing=locked for tools like aptLikely follow-up: How would you cache apt downloads safely with a cache mount?
Docker uses a union filesystem, typically overlayfs. The image's read-only layers are stacked as lower directories, each container gets a thin writable upper layer, and the container sees the merged view.
Copy-on-write means files are copied only when they change:
So containers start fast because nothing is copied up front, write-heavy data like databases belongs on volumes, which bypass the storage driver, and deleting files in a later Dockerfile instruction never shrinks an image.
Older installs use the overlay2 storage driver; fresh installs of Docker Engine 29 and later use the containerd image store with its overlayfs snapshotter, which follows the same model.
Likely follow-up: Why is a database on the container writable layer slower than on a volume?
--privileged, or mounting /var/run/docker.sock into it, dangerous?hardBoth effectively remove the container boundary.
--privileged gives the container all Linux capabilities and access to all host devices, and lifts the seccomp and AppArmor or SELinux confinement Docker normally applies. A root process in that container can mount the host's disks or change kernel settings, so escaping to the host is trivial. It's occasionally needed, for Docker-in-Docker or low-level system tools, but should be treated like running directly on the host.
Mounting the Docker socket hands the container full control of the Docker API. Anything that can talk to the socket can start a new privileged container with the host's root filesystem mounted and take over the machine. It's root-equivalent, even if the process using it isn't root.
Safer alternatives:
--cap-add) or device (--device) you need--privileged grants all capabilities and devices--cap-add or --device insteadLikely follow-up: What is the difference between Docker-in-Docker and mounting the host socket?
USER in the Dockerfile?hardThey're separate layers of defence. Setting USER makes the app run as a non-root user inside the container, which limits what it can do, but the Docker daemon still runs as root.
Rootless mode runs the daemon itself and all its containers as an unprivileged user, inside a user namespace. Root in a container maps to your ordinary host user, so even a full escape only gets that user's privileges. You set it up with dockerd-rootless-setuptool.sh install; it needs newuidmap and newgidmap plus subordinate ID ranges in /etc/subuid and /etc/subgid, and it gets its own socket and CLI context.
Limitations to know:
It also differs from userns-remap, where the daemon stays root but container UIDs are mapped to unprivileged host UIDs. Podman takes the rootless approach by default.
Likely follow-up: What does root inside a rootless container map to on the host?
.env file and env_file:? Which value wins if a variable is set in several places?midThey solve different problems:
.env file in the project directory is read by Compose itself for interpolation: it fills in ${TAG} style references in compose.yaml. Its values are not automatically passed into containersenv_file: on a service lists files whose variables are injected into that service's containersenvironment: sets container variables inline, and can use interpolated valuesWhen a container variable is set in several places, the order of precedence is: docker compose run -e on the command line, then environment:, then env_file:, then the image's ENV. For interpolation, variables from your shell override those in .env.
To check, docker compose config prints the fully resolved file, and docker compose exec <service> env shows what the container actually sees. Keep .env files with real credentials out of Git, and don't confuse either mechanism with Compose secrets, which mount files instead of setting variables.
# .env, next to compose.yaml: only used for ${...} substitution
TAG=16-alpine
# compose.yaml
services:
db:
image: postgres:${TAG}
env_file: db.env # injected into the container
environment:
POSTGRES_DB: app # also injected; wins over env_file.env feeds ${VAR} interpolation in the Compose fileenv_file injects variables into containers.env for interpolationdocker compose configLikely follow-up: How would you keep separate settings for dev and production with Compose?
exec format error. Why, and how do you fix it?hardImages are built for a specific CPU architecture. An Apple Silicon Mac builds linux/arm64 images by default, while most x86 servers need linux/amd64. When the kernel is asked to execute a binary for the wrong architecture, it fails with exec format error.
Fixes:
docker build --platform linux/amd64 .FROM --platform=$BUILDPLATFORM and use the automatic TARGETOS and TARGETARCH build args to choose the outputBuilding multi-platform images with the default builder needs the containerd image store, the default on fresh recent installs, or a docker-container builder. docker buildx imagetools inspect shows which platforms a tag supports.
exec format error means an architecture mismatch--platform or a multi-platform buildLikely follow-up: Why is emulated building slow, and how does cross-compilation avoid it?
WORKDIR and LABEL do, and why is RUN cd /app not the same as WORKDIR /app?easyWORKDIR sets the working directory for the instructions that follow, such as RUN, CMD, ENTRYPOINT, COPY and ADD, and for the running container. If the directory doesn't exist, it's created. A relative path is resolved against the previous WORKDIR, and you can use it as many times as you like.
RUN cd /app doesn't persist: each RUN starts a fresh shell, so the cd only applies inside that one command. That's why Dockerfiles that rely on cd end up with long cd x && ... chains, and why WORKDIR with absolute paths is the clearer, recommended approach.
LABEL adds key-value metadata to the image config without adding any files. Common uses are the standard OCI keys like org.opencontainers.image.source, version and revision, plus team or ownership labels. You can read them with docker inspect and filter with --filter label=..., and registries and scanners use them to link an image back to its source. LABEL replaces the deprecated MAINTAINER instruction.
FROM alpine:3.20
LABEL org.opencontainers.image.source="https://github.com/acme/api" \
org.opencontainers.image.version="1.4.0"
WORKDIR /app
RUN cd /tmp && touch scratch # the cd only lasts for this RUN
RUN pwd # prints /appRUN cd only lasts for that one RUNLikely follow-up: How would you stamp the Git commit into an image automatically?
Profiles let one Compose file contain optional services that only start when you ask for them. A service with a profiles attribute is enabled only when one of its profiles is active; services without profiles are always enabled.
You activate profiles with:
docker compose --profile debug up, repeatable for several profilesCOMPOSE_PROFILES=debug,tools environment variableExplicitly targeting a service also starts it even if its profile isn't active, so docker compose run --rm seed works for a one-off job.
Typical uses are debugging tools like database admin UIs, test runners, seed or migration jobs, and heavy optional dependencies. The default docker compose up stays fast and lean while everything lives in one file.
Two cautions: make sure anything an always-on service depends on is itself always on, and for bigger differences, like dev versus production settings, multiple files merged with -f or the include element usually fit better than profiles.
--profile or COMPOSE_PROFILESLikely follow-up: How would you override settings for production without duplicating the Compose file?
localhost inside a container is the container itself, so localhost:5432 won't reach the host. The options:
host.docker.internal, which resolves to the host--add-host=host.docker.internal:host-gateway, or extra_hosts in Compose. host-gateway is a special value that Docker replaces with the host's address--network host) on Linux puts the container in the host's network stack, so localhost really is the host, at the cost of isolationDon't forget the host side. On Linux, a service bound only to 127.0.0.1 won't accept connections arriving from the Docker bridge, so it may need to listen on the bridge address or all interfaces, and the host firewall has to allow it.
If the host service is really part of your stack, the cleaner long-term fix is to run it as a container on the same user-defined network.
# Docker Desktop: the name exists out of the box
docker run --rm alpine:3.20 nc -z host.docker.internal 5432
# Docker Engine on Linux: map the name to the host gateway yourself
docker run --rm --add-host=host.docker.internal:host-gateway alpine:3.20 \
nc -z host.docker.internal 5432host.docker.internal--add-host with host-gatewayLikely follow-up: How would you do the same thing in a Compose file?
Bind mounts pass host files straight through, and the kernel checks permissions by numeric UID and GID, not by user name. If the host directory is owned by UID 1000 and the container process runs as UID 10001, the kernel sees a different user and denies the write.
Fixes, depending on the situation:
--user "$(id -u):$(id -g)", common for dev tooling:z or :Z to the mountAvoid chmod 777 or running as root, which swaps a permissions problem for a security one. With rootless Docker or user namespaces, UIDs are shifted too, so check the mapping.
--user or fix host ownership:z or :ZLikely follow-up: Why do files created by a container in a bind mount end up owned by root on the host?
scratch image that has no shell?harddocker exec -it api sh fails because there's no sh in the image. Options, from least to most invasive:
docker logs, docker inspect, docker top, docker stats, and docker cp to copy files out--pid container:api you see its processes, and with --network container:api your localhost is the app's. You can even browse the app's filesystem through /proc/1/root. The tools come from the debug image and the target stays untoucheddocker debug, where your Docker Desktop version offers it, automates this and gives a shell with tools in any container or image:debug tags that include BusyBox, in a non-production buildkubectl debugThat's the trade-off of minimal images: production images stay small, and the tooling lives in a separate, temporary container.
# Join the target's PID and network namespaces from a tools container
docker run -it --rm \
--pid container:api --network container:api \
alpine:3.20 sh
# inside: ps, wget -qO- localhost:8080, ls /proc/1/root/appdocker exec sh fails/proc/1/rootdocker debug or :debug image variantsLikely follow-up: Why does /proc/1/root only work if the debug container shares the PID namespace?
Ephemeral CI runners start with an empty BuildKit cache, so every layer is rebuilt. The fix is to export the cache somewhere persistent with --cache-to and import it on the next run with --cache-from:
registry: stores the cache as a separate artifact in your registry, and works anywhereinline: embeds cache metadata in the pushed image; simple, but it doesn't scale well to multi-stage buildsgha: uses the GitHub Actions cache servicelocal, s3 and azblob: a directory or object storagemode=min, the default, caches only the layers that end up in the final image; mode=max also caches intermediate stages, which matters when the expensive work happens in a build stage.
More tips: make the Dockerfile cache-friendly first, with manifests before source and a good .dockerignore; use a per-branch cache ref that falls back to main's; and note that the default docker driver supports these backends only with the containerd image store, otherwise use a docker-container builder. Cache mounts aren't exported, so they help less on fresh runners.
docker buildx build \
--cache-from type=registry,ref=registry.example.com/api:buildcache \
--cache-to type=registry,ref=registry.example.com/api:buildcache,mode=max \
-t registry.example.com/api:$GIT_SHA --push .--cache-to, import with --cache-frommode=max also caches intermediate stagesLikely follow-up: What are the trade-offs between mode=min and mode=max?
On one host, a bridge network is enough. Across hosts, Docker uses an overlay network: a virtual network that spans several Docker daemons.
--attachable--opt encrypted encrypts application traffic with IPsec, at a performance costWithout Swarm, you'd route container subnets between hosts yourself, use macvlan or ipvlan, or publish ports on each host behind a load balancer. In practice, most teams now get multi-host networking from Kubernetes, where a CNI plugin plays this role.
--attachable lets standalone containers joinLikely follow-up: What is the Swarm routing mesh, and how does it differ from host-mode publishing?
No questions match that filter.
Prefer multiple choice? All 25 Docker MCQs with answers →