Docker Containers
Docker is a thin layer of convenience over Linux kernel features, and most of its surprises make sense once you see that. This page builds the model from the kernel up (namespaces, cgroups, layered filesystems), then works through the Dockerfile, the build cache and BuildKit, four production-grade Dockerfiles for a Spring Boot service that were built and run for this page with real sizes, how the JVM behaves under a memory limit, networking, storage, Compose with a health-gated database, tagging, security and troubleshooting.
What a Container Really Is
A container is not a small virtual machine. A VM emulates hardware and boots its own kernel. A container is a normal Linux process (or a few) that the kernel has been asked to treat specially in three ways: it is shown a restricted view of the system, it is given a resource budget, and it is given its own root filesystem. Everything else, the kernel, the scheduler, the network stack, is shared with the host and every other container.
| Kernel feature | What it does | What you see in practice |
|---|---|---|
| Namespaces | Give a process a private copy of a global resource: process IDs (pid), network stack (net), mount table (mnt), hostname (uts), IPC objects (ipc), cgroup root (cgroup), user IDs (user), clock offsets (time) | ps inside the container shows only your process as PID 1; ip addr shows only eth0 and lo |
| cgroups (v2) | Limit and account for CPU, memory, process count and I/O of a group of processes | docker run -m 512m writes 536870912 into the container's memory.max; exceeding it gets the process killed by the kernel (OOM killer) |
| Union filesystem (overlayfs) | Stack read-only image layers and one writable layer into a single merged /, with copy-on-write | Edits inside a container never change the image; deleting the container deletes the writable layer |
| Capabilities, seccomp, AppArmor | Cut down which privileged operations and system calls the process may use | A default container is not allowed to load kernel modules or change the system clock (chapter 10) |
The commands below were run on this machine (Docker Engine 29.4.1, cgroup v2). Each namespace is an inode number in /proc/self/ns. A container gets new numbers for most namespaces; the host keeps the originals.
Six namespaces differ, so the container has its own PIDs, network, mounts, hostname, IPC and cgroup view. **The user namespace is identical to the host's.** By default Docker does not remap user IDs, so UID 0 inside the container is UID 0 on the host, just with fewer privileges. That is why running as a non-root user (chapter 10) matters, and why rootless mode and user-namespace remapping exist.
cpu.max is quota period in microseconds: 100000 100000 means "100 ms of CPU time per 100 ms window", one CPU. The Java process is PID 1 in its own namespace and PID 2482937 on the host. Same process, two numbers.
docker run gets theredocker CLIdockerd) over a Unix socket (/var/run/docker.sock). Whoever can write to that socket effectively controls the hostdockerdcontainerdruncexecs your process. Then it exits; the shim stays as the container's parentThe pieces are specified by the Open Container Initiative (OCI): the image spec (what an image is), the runtime spec (how to run one) and the distribution spec (how registries serve them). That is why an image built with Docker runs on containerd, Podman or Kubernetes without change. On macOS and Windows, Docker Desktop runs a small Linux VM, because the container features above are Linux kernel features.
- All containers share one kernel. A kernel vulnerability, a container started with
--privileged, or a mounteddocker.sockbreaks the isolation. Containers are good isolation for packaging and resource control; for hostile multi-tenant code use the hardening in chapter 10, and consider sandboxed runtimes or VMs.
Images, Layers, Digests and Tags
An image is an immutable bundle: a manifest (the list of parts), a config (default command, environment, user, history) and an ordered list of layers. Each layer is a tar archive of filesystem changes. When a container starts, the runtime stacks the layers read-only and adds one empty writable layer on top.
| Concept | What it is | Mutable? |
|---|---|---|
| Layer | Tar archive of added, changed and deleted files, identified by the SHA-256 of its content | No: change one byte and it becomes a different layer |
| Manifest | JSON listing the config and layer digests (OCI image spec). A multi-platform image has an index pointing at one manifest per architecture | No |
| Digest | sha256: hash of the manifest. image@sha256:... names exactly one image forever | No |
| Tag | A human-friendly name (21-jre, 1.4.2, latest) that a registry maps to a manifest digest | Yes: the owner can repoint it any time |
| Image ID | Local identifier (hash of the config); what docker images shows | No |
Because a layer is named by its hash, two images that start from the same base store the base once on disk, and a registry transfers only the layers you do not have yet. That is why pulling a new version of your app is quick if only the top layer changed, and why chapter 4 spends so much effort on layer order.
Deleting a file in a later layer does not shrink the image. The earlier layer still contains it, and the later layer only adds a marker (a whiteout, a file named .wh.<name>). Chapter 10 shows what that means for secrets.
The digest above is the one this machine received on 2026-10-31. The 21-jre tag will point at a different digest after the next Temurin release, which is exactly the point of the next section.
| You write | You get | Use it for |
|---|---|---|
eclipse-temurin:21-jre | Whatever the tag points to today: new patch releases and a possibly different base OS (on this machine it resolved to Ubuntu 26.04) at the next pull | Local experiments |
eclipse-temurin:21.0.11_10-jre | A specific build, though a tag is still repointable in principle | Reproducible-enough builds, with a bot (Renovate, Dependabot) to bump it |
eclipse-temurin@sha256:0b99b3... | Exactly one image, byte for byte | Production deployments and base-image pinning in security-sensitive pipelines |
myapp:latest | latest is just a tag name, the default when you omit one. It does not mean newest | Nothing in production |
latest trap
latestis whatever was pushed last without a tag, or whatever the publisher decided. Two nodes pullingmyapp:latestat different times run different code, a rollback cannot go "back to latest", anddocker pullon a restarted node silently upgrades you. Deploy immutable version tags or digests (chapter 9), neverlatest.
Dockerfile Instruction Reference
A Dockerfile is a recipe. Each instruction that changes the filesystem (RUN, COPY, ADD) produces a layer; the others (ENV, CMD, USER, ...) change the image config. Defaults below were checked against the Dockerfile reference on docs.docker.com.
| Instruction | What it does | Notes and gotchas |
|---|---|---|
FROM image[:tag|@digest] AS name | Starts a build stage from a base image | Several FROMs make a multi-stage build (chapter 4). Pin the tag; FROM scratch is an empty image |
RUN cmd | Runs a command at build time and commits the filesystem change as a layer | Chain related commands with && so cleanup happens in the same layer |
COPY src dest | Copies files from the build context (or --from=stage) | Preferred over ADD. --chown, --chmod, --link are available |
ADD src dest | Like COPY, plus it can fetch a URL and auto-extract local tar archives | The surprise features are why COPY is the default choice. Use ADD deliberately (for example --checksum on a URL) |
WORKDIR /path | Sets the working directory for later instructions and for the running container | Creates the directory if needed. Avoid RUN cd ... && across instructions |
ENV KEY=value | Sets an environment variable in the image and in the running container | Visible in docker inspect; not for secrets |
ARG NAME=default | Build-time variable, set with --build-arg | Gone at runtime, but **recorded in docker history**; never pass secrets this way |
EXPOSE 8080 | Documents the port the app listens on | Does not publish anything. Publishing is -p at run time |
USER uid:gid | Sets the user for later RUNs and for the container process | Prefer a numeric UID so Kubernetes runAsNonRoot can verify it |
HEALTHCHECK | Defines a command Docker runs to decide healthy or unhealthy | Needs the tool (such as curl) inside the image; distroless images have none |
ENTRYPOINT / CMD | What runs when the container starts | See below: form and combination matter |
STOPSIGNAL, VOLUME, LABEL, SHELL, ONBUILD | Stop signal, declared volume mount points, metadata, default shell for shell form, triggers for child images | VOLUME in a Dockerfile makes an anonymous volume per container; most teams declare volumes at run time instead |
ENTRYPOINT is what the container is; CMD is the default arguments (or the default command if there is no entrypoint). Arguments after the image name in docker run replace CMD but are appended to ENTRYPOINT. --entrypoint replaces the entrypoint.
| Dockerfile | docker run img | docker run img --server.port=9090 |
|---|---|---|
ENTRYPOINT ["java","-jar","app.jar"] | runs java -jar app.jar | runs java -jar app.jar --server.port=9090 (appended) |
CMD ["java","-jar","app.jar"] | runs java -jar app.jar | tries to run --server.port=9090 as a command and fails (replaced) |
ENTRYPOINT ["java","-jar","app.jar"] + CMD ["--spring.profiles.active=dev"] | runs java -jar app.jar --spring.profiles.active=dev | the CMD default is replaced by your argument |
CMD java -jar app.jar (shell form) is run as /bin/sh -c "java -jar app.jar". CMD ["java","-jar","app.jar"] (exec form, a JSON array) runs the program directly. The difference decides who is PID 1, and PID 1 is special: the kernel gives it no default signal handlers, so a SIGTERM sent by docker stop is ignored unless the process installs a handler itself. A shell does not forward signals to its child.
We measured it with docker stop (default grace period 10 s, then SIGKILL) on this machine:
| Container | PID 1 was | docker stop took | Exit code |
|---|---|---|---|
CMD sleep 300 (shell form) | /bin/sh -c sleep 300 | 10.5 s | 137 (killed) |
CMD ["sleep","300"] (exec form) | sleep | 10.5 s | 137: sleep has no SIGTERM handler, and PID 1 ignores signals without one |
ENTRYPOINT ["/start.sh"] that runs sleep 300 | /bin/sh /start.sh | 10.5 s | 137 |
docker run --init with the exec form | docker-init, with sleep as its child | 0.6 s | 143 (SIGTERM) |
Spring Boot, exec form ENTRYPOINT ["java","-jar",...] | java | 1.1 s | 143, graceful shutdown logged |
Spring Boot, shell form ENTRYPOINT java -jar ... && echo done | /bin/sh -c ..., java was PID 7 | 10.7 s | 137 |
So the rule is not just "use exec form". It is: the process that is PID 1 must handle SIGTERM. The JVM does (it runs shutdown hooks, Spring Boot closes the web server gracefully and exits 143). A shell, sleep or a script does not. Fixes, in order of preference: exec form with a program that handles signals; exec java ... as the last line of any wrapper script so the shell replaces itself; or docker run --init / init: true in Compose to put a tiny init (tini, built into Docker) in front.
The build context is the directory you pass to docker build (usually .). It is sent to the builder in full, so a missing .dockerignore means .git, target/, node_modules and .env travel with every build and can end up inside a COPY . . layer. A .dockerignore file in the context root lists what to leave out; later !pattern lines add exceptions.
- The first version of this sample had
target/in.dockerignoreandCOPY target/shop-1.0.0.jarin the Dockerfile. The build failed with"/target/shop-1.0.0.jar": not found, because the file was never sent to the builder. Hence thetarget/*plus!target/*.jarpair above. When aCOPYsays "not found" for a file that exists, check.dockerignorefirst.
The Build Cache, BuildKit and Multi-Stage Builds
BuildKit is the build engine behind docker build (the default since Docker Engine 23.0) and behind docker buildx build. It runs independent stages in parallel, skips stages nobody needs, and offers features the classic builder never had: cache mounts, secret mounts and multi-platform output. The first line # syntax=docker/dockerfile:1 selects the current Dockerfile frontend so newer syntax works on any recent engine.
- Docker compares each instruction with the cache. For
RUNit compares the instruction text (not what the command would produce); forCOPYandADDit compares a checksum of the files copied. - As soon as one instruction misses the cache, every later instruction runs again.
- So order instructions from least to most frequently changing: base image, OS packages, dependency manifest, dependency download, source code, build.
- A
RUN apt-get update && apt-get install -y xstays cached forever, even when packages have moved on. Rebuild with--pull --no-cacheon a schedule so base images and packages get refreshed.
Measured with the multi-stage Dockerfile of chapter 5 (warm Maven cache mount, this machine): editing one Java file left COPY pom.xml, mvn dependency:go-offline and the dependency, loader and snapshot layers CACHED, and re-ran only COPY src, the Maven package step and the final application layer. A first, completely cold build (image pulls plus all Maven downloads) took 82 s here; rebuilds with a warm cache took roughly 14 to 25 s, dominated by the Maven package step. Treat these as one machine's numbers, not a benchmark.
| Feature | Syntax | Why |
|---|---|---|
| Cache mount | RUN --mount=type=cache,target=/root/.m2 mvn package | Keeps the Maven or Gradle download cache between builds without putting it in any layer. Even when the layer is re-run, dependencies are not downloaded again |
| Secret mount | RUN --mount=type=secret,id=api_token ... with docker build --secret id=api_token,src=token.txt . | The secret appears as a file under /run/secrets/api_token for that one RUN and is not stored in any layer or in docker history |
| SSH mount | RUN --mount=type=ssh git clone git@... | Use your SSH agent to fetch private repositories without copying keys in |
**Multi-stage + --target** | docker build --target build -t app:build . | Build only up to a named stage (for tests, or to debug a build stage) |
| Registry cache | --cache-from type=registry,ref=... and --cache-to type=registry,ref=...,mode=max | CI runners start empty; export and import the cache through a registry |
| Attestations | --sbom=true --provenance=mode=max | Attach an SBOM and build provenance to the pushed image (flags exist in docker buildx build --help here; not run for this page) |
| Heredocs | RUN <<EOF ... EOF | Multi-line scripts without a chain of && and backslashes |
A multi-stage Dockerfile has several FROM lines. Early stages carry the compiler, Maven, test tools and source; the final stage starts from a slim runtime image and copies in only the build output with COPY --from=build. Nothing from the earlier stages ships. The result is smaller, has fewer vulnerabilities to scan, and never contains your source or build credentials.
Multi-platform images (for example linux/amd64 and linux/arm64, so the same tag runs on Intel servers and Apple Silicon or Graviton) are built with docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/shop:1.4.2 --push .. A multi-platform result is an image index with one manifest per architecture, so it has to be pushed (or exported as an OCI archive) rather than loaded into a single local image store. It needs a builder that supports multiple platforms (see the buildx docs; QEMU emulation or native builders). The flags above exist in the buildx installed here (v0.30.1); the multi-platform build itself was not run for this page.
Production Dockerfiles for a Spring Boot App
The sample is a small Spring Boot 3.5.16 service (Java 21, Maven) with spring-boot-starter-web, Actuator and a JdbcTemplate that talks to PostgreSQL. It exposes /hello, /memory and /actuator/health/readiness. Every Dockerfile below was built and run on this machine (Docker Engine 29.4.1). The Spring Boot facts were checked against the Spring Boot reference for 4.1.2 on docs.spring.io, and the commands were run against the 3.5.16 jar.
It works, and it is the version in most tutorials. Its problems: the JDK (compiler, jshell, tools) ships to production; it runs as root; the 25 MB jar is one layer, so changing one line of your code re-uploads and re-downloads all 25 MB; and running an uber jar un-unpacked adds startup overhead (Spring Boot docs call this out under "Efficient Container Images").
Spring Boot jars built by the Maven or Gradle plugin carry a layer index (BOOT-INF/layers.idx) that splits the jar into dependencies, spring-boot-loader, snapshot-dependencies and application, ordered from least to most likely to change. A helper mode of the jar unpacks those directories so each becomes its own Docker layer.
The current command is java -Djarmode=tools -jar application.jar extract --layers --destination extracted (Spring Boot reference, "Dockerfiles"; the jar also offers list-layers). The older -Djarmode=layertools form is gone: on the 3.5.16 jar built for this page java -Djarmode=layertools -jar shop-1.0.0.jar lists only help. Old tutorials that say layertools extract will fail on current Spring Boot.
The extraction runs in a throw-away first stage; the runtime stage copies the four directories in order of volatility. Checking the result with docker history: the dependency layer is 25.7 MB, the loader and snapshot layers are about 4 kB each, and the application layer is 16.4 kB. After a code change only that 16 kB layer is rebuilt, pushed and pulled.
spring-boot:build-image needs a running Docker daemon and builds an OCI image with Cloud Native Buildpacks; the default builder in the Spring Boot docs is paketobuildpacks/builder-noble-java-tiny:latest, and the resulting image runs as a non-root user (docs: "images build and run as non-root users"). It sets up the JVM memory configuration for you and layers the image sensibly. The trade-off is less control: you tune through plugin properties and BP_*/BPL_* environment variables, not Dockerfile lines. It is a good default for teams that do not want to own Dockerfiles.
Smaller images mean faster pulls and fewer packages to patch. Distroless images from Google contain the runtime and its libraries but no shell and no package manager. The names below exist and were pulled for this page: gcr.io/distroless/java21-debian13:nonroot (also java21-debian12), gcr.io/distroless/cc-debian13:nonroot. The :nonroot tag runs as UID 65532.
For the last step, jlink builds a custom JRE containing only the JDK modules the application needs, which can then sit on a minimal base such as distroless/cc (it provides glibc and libstdc++ but no Java).
- The first attempt used
jdeps --class-path ... application.jarand produced a runtime with onlyjava.base. The container failed at start-up withNoClassDefFoundError: java/beans/PropertyEditorSupport(Spring needsjava.desktop), and after a partial fix withjavax/management/MBeanRegistration. The working command passes the application jar and every dependency jar tojdeps, which printedjava.base, java.compiler, java.desktop, java.instrument, java.net.http, java.prefs, java.rmi, java.scripting, java.security.jgss, java.sql.rowset, jdk.jfr, jdk.management, jdk.unsupportedfor this app, andjdk.crypto.ecis added by hand for TLS.jdepscannot see classes loaded by name at run time, so always start the final image in CI and hit a health endpoint before trusting a jlink runtime.
| Variant | Base image | Disk usage | Content size | Runs as | Shell inside? |
|---|---|---|---|---|---|
| 1 naive fat jar | eclipse-temurin:21-jdk (Ubuntu 26.04) | 773 MB | 248 MB | root | yes |
| 2 layered, JRE, non-root | eclipse-temurin:21-jre | 506 MB | 139 MB | 10001 | yes |
| 3 distroless | distroless/java21-debian13:nonroot | 311 MB | 86.7 MB | 65532 | no |
| 4 jlink + distroless/cc | distroless/cc-debian13:nonroot + custom runtime | 197 MB | 74 MB | 65532 | no |
| Buildpacks | Paketo builder-noble-java-tiny run image | 543 MB | 263 MB | non-root | no |
For reference, the bare base images on the same machine: eclipse-temurin:21-jdk 729 MB, eclipse-temurin:21-jre 462 MB, eclipse-temurin:21-jre-alpine 283 MB, distroless/java21-debian13:nonroot 262 MB (disk usage). Disk usage is the unpacked size on disk; content size is the compressed size that moves over the network. Alpine uses musl instead of glibc; test your native dependencies before choosing it. All five variants were started and served /actuator/health/liveness.
- Variant 2 (JRE, layered, non-root) is the sensible default: you can still
docker execinto it and the base is a well-known supported image. Go to distroless or jlink when your scanner results, pull times or compliance rules justify losing the shell. Docker Debug (docker debug) and Kubernetes ephemeral containers (page 04) are the answer to "but I cannot exec into it".
A newer option from the Spring Boot docs: with Java 25 or later, the layered Dockerfile can add a training run (java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar) and start with -XX:AOTCache=app.aot for faster startup; for Java 24 and earlier the docs show the CDS equivalent. This page uses Java 21 and did not run it.
The JVM Inside a Container
Modern JVMs are container-aware: on JDK 21, UseContainerSupport is true by default, and the JVM reads the cgroup limits instead of the host totals to size the heap and the default thread pools. Three things still bite people, and all were measured here.
The conservative default leaves room for non-heap memory but wastes a lot of a small container. A starting point of **-XX:MaxRAMPercentage=70 to 75** is common; measure docker stats and tune. (InitialRAMPercentage defaults to about 1.56%, MinRAMPercentage to 50% and only matters for very small limits.)
Spring Boot service with -XX:MaxRAMPercentage=75, started with -m 512m --cpus 1 in Compose: /memory answered {"cpus":1,"maxHeapMiB":371}. (371 rather than 384 because the JVM reports the usable heap, which excludes one survivor space.) The same image started with no limit on this 31 GiB machine reported a maximum heap of 23808 MiB, 75% of the host. A percentage flag without a container limit is a request to use most of the machine. Always set both.
- Non-heap memory: metaspace (loaded classes), thread stacks (default
ThreadStackSizeis 1024 KiB per thread, and Tomcat has 200 request threads by default), the JIT code cache (ReservedCodeCacheSize240 MiB reserved on this JDK, usually far less committed), directByteBuffers used by Netty and NIO, GC data structures and the C library. - Rule of thumb: container limit = max heap + a few hundred MiB of overhead, then verify under load. With a 512 MiB limit, a 75% heap leaves 128 MiB for everything else, which a busy service may exceed.
Runtime.availableProcessors()follows--cpus: the JVM sizedcpusto 1 under--cpus 1and to 2 under--cpus 2(shown by-XshowSettings:system:Effective CPU Count: 2,Memory Limit: 256.00M).ForkJoinPool, GC thread counts and Tomcat tuning follow it.-XX:ActiveProcessorCount=Noverrides it.
| Symptom | Cause | Fix |
|---|---|---|
Stack trace with java.lang.OutOfMemoryError: Java heap space; container exit 1 (or the pod keeps running, degraded) | Heap too small for the live data, or a leak | Raise MaxRAMPercentage (and the limit), fix the leak, take a heap dump |
Container disappears, exit 137, OOMKilled=true, no Java stack trace | Total process memory exceeded memory.max; the kernel killed it | Lower MaxRAMPercentage, raise the limit, cap direct memory (-XX:MaxDirectMemorySize), reduce threads |
Exit 137, OOMKilled=false | Something sent SIGKILL: docker stop timed out (chapter 3), or the orchestrator killed it | Handle SIGTERM; check docker events and node logs |
| Latency spikes only in containers with a CPU limit | CFS throttling: the quota is used up early in each 100 ms window | Raise --cpus, reduce thread counts, check cpu.stat for nr_throttled |
More on heap sizing, GC choice and what to measure is on the JVM Memory and GC page; the Kubernetes meaning of requests and limits is on page 04.
Networks, Ports, Volumes, Bind Mounts and tmpfs
| Driver | What it is | Use |
|---|---|---|
bridge (default) | A software switch on the host. Each container gets an IP on a private subnet and outbound traffic is NATed | Single-host containers; the docker0 default network and user-defined networks |
user-defined bridge | Same driver, created with docker network create. Adds automatic DNS by container or service name and better isolation | What Compose creates for you. Prefer it to the default bridge |
host | The container shares the host network namespace; no NAT, no isolation of ports | Rare: maximum network performance or many ports. Linux Engine only in the usual configuration |
none | Only a loopback interface | Batch jobs that must not touch the network |
overlay, macvlan | Multi-host networks (Swarm), containers with their own MAC on the LAN | Specialised; Kubernetes uses its own CNI plugins (page 03) |
Publishing a port maps a host port to a container port: -p 8080:8080 (all host interfaces), -p 127.0.0.1:8080:8080 (host loopback only, which is what you want for a database during development), -p 8080 (random host port, see docker port). EXPOSE in the Dockerfile only documents; it publishes nothing.
localhostinside a container is the container itself. A Spring Boot app that hasjdbc:postgresql://localhost:5432/shopin its configuration works on your laptop and fails in Compose withConnection refused. Use the service name (db) on a shared network, and take the URL from an environment variable (DB_URL), as the sample'sapplication.propertiesdoes.
| Type | Flag | Lives | Use it for |
|---|---|---|---|
| Volume | -v dbdata:/var/lib/postgresql/data or --mount type=volume,src=dbdata,dst=... | In Docker's area (/var/lib/docker/volumes/<name>/_data on Linux); managed with docker volume | Database files and any data that must outlive the container. Portable and the default recommendation |
| Bind mount | -v /host/path:/container/path[:ro] | Any host path you name | Source code in development, config files, reading host files read-only |
| tmpfs | --tmpfs /scratch:rw,size=16m | RAM only, gone when the container stops | Scratch space with a read-only root filesystem; data you must not persist |
- The writable container layer is slow and ephemeral: heavy writes (database files, uploads) belong on a volume.
- A bind mount to a directory owned by root is not writable by a non-root container user (UID 10001 in our image). Fix ownership on the host or use a volume.
docker compose downkeeps named volumes;docker compose down -vdeletes them. That flag is how people lose a development database.- In production on Kubernetes or ECS, persistent data uses the platform's volume types (EBS, EFS), not Docker volumes (page 03, ECS).
Docker Compose: App, PostgreSQL and a Health-Gated Start
Docker Compose describes a multi-container application in one compose.yaml file (the Compose Specification) and starts it with one command. It creates a project network, volumes and containers, injects the service names into DNS, and tears everything down again. It is the right tool for local development and CI integration tests; for production orchestration see Kubernetes (pages 03 and 04).
name: shopfixes the project name (container names becomeshop-app-1,shop-db-1, and the networkshop_backend). The old top-levelversion:field is no longer needed.depends_onwithcondition: service_healthymakes Compose wait until thedbhealth check passes. The other conditions areservice_started(the default) andservice_completed_successfully(for one-shot jobs such as migrations).healthcheckondbusespg_isready, which ships in the PostgreSQL image. The app's check usescurlagainst Spring Boot's readiness endpoint, which works because theeclipse-temurinimage containscurl; a distroless image would need a different mechanism.${DB_PASSWORD:?message}stops Compose with an error if the variable is unset, instead of starting a database with an empty password. Never commit the real.env.mem_limitandcpusare plain service attributes; the memory limit is what made the JVM size its heap at 371 MiB in chapter 6. (deploy.resources.limitsexpresses the same thing and is also honoured bydocker composetoday.)restart: unless-stoppedrestarts the app after a crash or a daemon restart, but not after you stopped it deliberately.
- Plain
depends_onorders container start, not readiness. Use the health-gated form, and still make the application tolerant of a database that disappears and returns (connection retries, and in Kubernetes a readiness probe). Health gating is a development and CI convenience, not a substitute for resilient clients.
| Command | What it does |
|---|---|
docker compose up -d --wait | Start in the background and block until services are running/healthy |
docker compose ps / logs -f app / top | Status, follow one service's logs, processes |
docker compose exec db psql -U shop shop | Run a command in a running service container |
docker compose build / up --build | Build images defined with build: and start |
docker compose config | Print the fully resolved file (variables substituted); -q just validates |
docker compose down / down -v | Remove containers and networks / and named volumes (data loss) |
docker compose --profile debug up | Start services that carry profiles: [debug] |
Spring Boot also has a spring-boot-docker-compose module that can start a compose.yaml for you when the application starts and wire connection details automatically (see the Docker Compose section of the Spring Boot reference). It is a development convenience and is excluded from the packaged image by default.
Registries and a Tagging Strategy
A registry stores images and serves them over the OCI distribution API: Docker Hub, GitHub Container Registry, Amazon ECR, Google Artifact Registry, Azure Container Registry, Harbor. An image name has up to four parts: registry.example.com/team/shop:1.4.2 is registry registry.example.com, repository team/shop, tag 1.4.2. With no registry the name means Docker Hub (docker.io/library/...).
| Strategy | Example | Verdict |
|---|---|---|
latest only | shop:latest | Do not deploy. Unrepeatable, no rollback target |
| Semantic version | shop:1.4.2 | Good for humans and release notes. Make it immutable |
| Git SHA | shop:3f9c1ab | Perfect traceability from a running container to a commit. Meaningless to humans on its own |
| Version + SHA | shop:1.4.2-3f9c1ab | Recommended: readable and traceable. CI pushes it once and never overwrites it |
| Moving convenience tags | shop:1.4, shop:1, shop:main | Fine for dev environments and consumers who want auto-updates; do not use them to deploy production |
| Digest | shop@sha256:ab12... | The strongest pin. Kubernetes and Compose accept it; tooling (Renovate, Argo CD image updater) can bump it for you |
- Build once, promote the same image. The artifact tested in staging is the artifact in production, referenced by the same digest. Rebuilding per environment tests a different image.
- Make tags immutable where the registry supports it (Amazon ECR has a tag-immutability repository setting) so a second push to
1.4.2is rejected rather than silently replacing it. - Add labels that link back to the source (
org.opencontainers.image.source,.revision,.version), so anyone with a running image can find its commit. - Expire untagged and old images with a registry lifecycle policy, or storage grows without bound. Keep whatever is currently deployed plus a rollback window.
- Scan on push and again on a schedule: a clean image today can have a known CVE next week (chapter 10).
The script uses registry.example.com, a placeholder; it was syntax-checked, not run against a registry.
- Re-pushing
1.4.2after "a small fix" means two clusters can run different code under the same version,imagePullPolicy: IfNotPresentnodes keep the old one, and your scanner report no longer describes what is deployed. A fix is1.4.3.
Image and Runtime Security
Layers are tar files that anyone with the image can read. The Dockerfile below copies a token file, uses it, then deletes it, and also takes the token as a build argument. It builds fine, and the secret is still in the image twice.
Three leaks from three lines: the ARG value in the image history, the COPY'd file inside its layer, and a rm that hides nothing. The fix is the BuildKit secret mount from chapter 4, which never writes the secret into a layer. At run time, inject secrets as environment variables from the orchestrator's secret store, or as mounted files (Compose secrets:, Kubernetes Secrets, AWS Secrets Manager), not as ENV in the image. Also keep .env and credentials out of the build context with .dockerignore.
| Control | docker run | Compose | Why |
|---|---|---|---|
| Non-root user | -u 10001:10001 (or USER in the Dockerfile) | user: "10001:10001" | Limits what a compromised process can do; the user namespace is not remapped by default (chapter 1) |
| Read-only root filesystem | --read-only --tmpfs /tmp | read_only: true, tmpfs: [/tmp] | Malware cannot persist or modify binaries; Spring Boot needs a writable /tmp (Tomcat work directory) |
| Drop capabilities | --cap-drop ALL then --cap-add what you need | cap_drop: [ALL] | Removes root-like powers; a web service needs none |
| No privilege escalation | --security-opt no-new-privileges | security_opt: [no-new-privileges:true] | Blocks setuid binaries from gaining privileges |
| Process limit | --pids-limit 200 | pids_limit: 200 | Contains fork bombs |
| Resource limits | -m, --cpus | mem_limit, cpus | Noisy neighbours and runaway memory (chapter 11) |
- Never run
--privilegedor mount/var/run/docker.sockinto an application container; either one is equivalent to root on the host. - Do not publish database ports to
0.0.0.0on shared machines (-p 127.0.0.1:5432:5432). - Rootless Docker and user-namespace remapping make container root a regular host user, at the cost of some features; consider them on shared build hosts.
A scanner unpacks the image, inventories the OS packages and the language libraries (Maven jars here), and matches them against vulnerability databases. We scanned two of the images with Trivy 0.76.1 (run from its container image against docker save tarballs, on 2026-10-31, so the counts are a snapshot of that day's database).
| Image | OS packages (HIGH/CRITICAL) | Java jars (HIGH/CRITICAL) |
|---|---|---|
shop:2-layered (Temurin JRE on Ubuntu 26.04) | 0 | 12 (6 HIGH, 6 CRITICAL) |
shop:3-distroless (Debian 13.7) | 4 HIGH | 12 (the same jar, so the same findings) |
Two lessons. The application's own dependencies often dominate: the Java findings are identical in every variant, because the jar is identical, and were reported in Jackson, embedded Tomcat, Spring MVC and the PostgreSQL driver of this particular dependency set. A smaller base image does not fix them; upgrading the dependencies does (Spring Boot patch releases bump the managed versions). And counts are not equally actionable: triage by whether a fixed version exists and whether the vulnerable code path is reachable.
- Docker Scout (
docker scout quickview,docker scout cves) is the scanner integrated into Docker Desktop and Hub. The CLI plugin is installed here, but it needs a Docker login, so it was not run for this page. - SBOM (software bill of materials): a machine-readable inventory of what is in the image. BuildKit can attach one with
--sbom=true(flag present, not run here); Spring Boot can also expose its own dependency list through the Actuatorsbomendpoint. Scanners can consume an SBOM instead of re-inspecting the image, and an SBOM lets you answer "are we affected?" the day a new CVE is published. - Provenance attestations (
--provenance=mode=max) record how and where an image was built. Signing (cosign/Notary) lets clusters admit only images your pipeline produced. - Rebuild on a schedule even without code changes: base images get patched, and a pinned digest stays vulnerable until you bump it.
Limits, Logging, Troubleshooting and Common Mistakes
| Flag | Effect | Gotcha |
|---|---|---|
-m 512m / --memory | Hard memory cap (memory.max); exceed it and the OOM killer acts | Containers without it can use all host memory. --memory-swap equal to -m disables swap |
--memory-reservation | Soft limit hint under pressure | Not a guarantee |
--cpus 1.5 | CPU quota: 150 ms per 100 ms period (cpu.max) | Throttling, not queueing: bursty services see latency spikes (chapter 6) |
--cpu-shares | Relative weight when CPUs are contended | Only matters under contention |
--pids-limit | Maximum number of processes/threads | A JVM with thousands of threads needs a generous value |
--ulimit nofile=65536 | Per-process limits such as open files | Default is often fine; high-connection servers need more |
The app uses 149 MiB of its 512 MiB while idle, with a 384 MiB heap ceiling; docker stats shows the container total (heap plus everything else), which is what the kernel compares with the limit.
- Write logs to stdout and stderr, not to files inside the container. Spring Boot logs to the console by default; do not add a file appender in a container.
- The default driver is
json-file, and by default no log rotation is performed, so a chatty container can fill the disk (Docker logging docs). Setmax-sizeandmax-file, or switch to thelocaldriver, which rotates by default. - Other drivers ship logs off the host:
syslog,journald,fluentd,gelf,awslogsand more. Recent Engine versions keep a local copy as well, sodocker logsstill works with them ("dual logging"). - The default delivery mode is blocking: if the driver is slow, the application's writes to stdout block.
mode: non-blockinguses a buffer instead, at the risk of dropping messages when it is full.
| Exit code | Meaning | Typical cause |
|---|---|---|
| 0 | Process finished normally | A one-shot command; or the main process daemonised and PID 1 exited |
| 1 | Application error | Uncaught exception at start-up; read docker logs |
| 125 | docker run itself failed | Bad flag, daemon error |
| 126 / 127 | Command cannot be invoked / not found | Missing executable bit, wrong ENTRYPOINT path, no shell in a distroless image |
| 137 | 128 + 9, SIGKILL | OOMKilled (check State.OOMKilled), or docker stop timed out and killed it |
| 139 | 128 + 11, SIGSEGV | Native crash (JNI library, musl/glibc mismatch) |
| 143 | 128 + 15, SIGTERM | Normal graceful stop of a JVM that handled the signal. Not an error |
docker ps -a shows Exited (1). Run docker logs <name> first: nine times out of ten the stack trace is there. Then docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' <name>docker run --rm -it --entrypoint sh image to explore the image (a distroless image has no shell; use a debug image or docker debug)docker exec -it <name> sh, docker top, docker stats, docker diff <name> to list files changed in the writable layerdocker inspect -f '{{json .NetworkSettings.Networks}}' <name>, docker network inspect <net>, test DNS with getent hosts <service> from insidedocker events --since 30m shows die, oom, kill, health_status and restart events with timestamps| Mistake | What you see | Fix |
|---|---|---|
COPY . . before dependency resolution | Every build re-downloads all dependencies | Copy the manifest, resolve dependencies, then copy sources (chapter 4) |
Shell-form ENTRYPOINT, or a script without exec | docker stop takes 10 s, exit 137, no graceful shutdown | Exec form, exec java ..., or --init (chapter 3) |
| Running as root, full JDK, Maven in the runtime image | Big image, long scan reports, privileged process | Multi-stage build, JRE, numeric non-root USER |
No memory limit, or MaxRAMPercentage without one | Heap sized from the host (23808 MiB measured) and the host swaps | Set the limit and the percentage (chapter 6) |
| Heap equal to the container limit | Exit 137, OOMKilled=true, no stack trace | Heap 70-75% of the limit, leave room for non-heap |
Secrets in ENV, ARG or COPY | Visible in docker history and in layer tars | BuildKit secret mounts at build, orchestrator secrets at run time (chapter 10) |
localhost for another service | Connection refused only inside containers | Service name on a user-defined network |
Deploying latest | Unrepeatable rollouts, surprise upgrades | Version-plus-SHA tags or digests (chapter 9) |
| Database data in the writable layer | Data gone after docker rm or recreate | Named volume |
| No log rotation | Disk full from /var/lib/docker/containers/*/*-json.log | local driver or max-size/max-file (chapter 11) |
| One container, many processes (sshd, cron, app) | Unclear health, signal handling, restarts | One main process per container; sidecars for the rest |
Ignoring .dockerignore | Huge build context, secrets or target/ in layers; or a needed file excluded | Keep it small and explicit (chapter 3) |
Interview Questions and Answers
- Q: What is a container, and how is it different from a VM?
A container is an ordinary process (or group) on the host kernel, isolated with namespaces (what it sees), limited with cgroups (what it can use) and given its own root filesystem through a layered union filesystem. A VM runs a full guest OS on virtualised hardware with its own kernel. Containers start in milliseconds and are light, but share the kernel, so isolation is weaker. - Q: Name the Linux features Docker is built on.
Namespaces (pid, net, mnt, uts, ipc, user, cgroup, time), cgroups for CPU, memory, pids and I/O limits, overlayfs for layered copy-on-write filesystems, plus capabilities, seccomp and AppArmor/SELinux for privilege reduction. - Q: What is an image layer, and why does instruction order matter?
A layer is a content-addressed tar of filesystem changes. Docker reuses a cached layer only if its instruction and all earlier ones are unchanged, so put rarely changing, slow steps (OS packages, dependency download) before frequently changing ones (source code). Good ordering makes rebuilds and pushes small. - Q: Tag versus digest? What is wrong with
latest?
A tag is a movable pointer; a digest is the hash of the manifest and names one image immutably.latestis only the default tag name, not "newest", so deployments using it are not reproducible and cannot roll back. Deploy immutable version tags or digests. - Q:
CMDversusENTRYPOINT; shell form versus exec form?ENTRYPOINTdefines the executable,CMDthe default arguments, anddocker runarguments replaceCMDbut are appended toENTRYPOINT. Shell form wraps the command in/bin/sh -c, so the shell is PID 1 and does not forward SIGTERM; exec form runs the program directly. PID 1 must handle SIGTERM ordocker stopwaits 10 s and then SIGKILLs (exit 137). - Q:
COPYversusADD?COPYcopies files from the context or another stage.ADDalso downloads URLs and auto-extracts local tar files, which are surprising behaviours, so useCOPYunless you need those features deliberately. - Q:
ARGversusENV?ARGexists only at build time (and is recorded in the image history);ENVpersists in the image and the running container. Neither is safe for secrets: use BuildKit secret mounts at build time and runtime secret injection. - Q: How do multi-stage builds help?
Build tools and sources stay in early stages; the final stage starts from a slim runtime image and copies only the artifacts. The image is smaller, has less attack surface and never ships your source or build credentials. - Q: How do you build an efficient Docker image for Spring Boot?
Build the layered jar, extract it withjava -Djarmode=tools -jar app.jar extract --layers, copydependencies,spring-boot-loader,snapshot-dependenciesandapplicationas separate layers in that order, run on a JRE base as a non-root user with exec-formENTRYPOINT. Alternatively usemvn spring-boot:build-image(Cloud Native Buildpacks). Code changes then rebuild only a tiny top layer. - Q: Why can a Java container be OOMKilled although the heap is fine?
The cgroup limit counts total process memory: heap plus metaspace, thread stacks, code cache, direct buffers, GC structures and native libraries. If heap equals the limit, the kernel kills the process (exit 137,OOMKilled=true) with no Java stack trace. SetMaxRAMPercentagearound 70-75 and leave overhead. - Q: How does the JVM know the container limits?
Since JDK 10 (backported to 8u191),UseContainerSupportreads cgroup limits to size the heap (default 25% of the limit viaMaxRAMPercentage), CPU count and thread pools. Without a limit it sees the host, so set-mand--cpus. - Q: Bridge vs host networking? How do containers find each other?
Bridge gives each container a private IP behind NAT; you publish ports with-p. Host shares the host's network namespace. On a user-defined bridge network, Docker's embedded DNS resolves container and service names; the default bridge does not. - Q: Volume versus bind mount versus tmpfs?
Volumes are managed by Docker and are the default for persistent data; bind mounts expose a specific host path (development, config); tmpfs is RAM-backed and ephemeral. Writes to the container's own layer are slow and lost on removal. - Q: How do you make Compose wait for a database to be ready?
Give the database ahealthcheck(pg_isready) and usedepends_on: db: condition: service_healthy. Plaindepends_ononly orders start. The application should still retry connections. - Q: How do you keep secrets out of an image?
NeverCOPY,ARGorENVthem. UseRUN --mount=type=secretat build time (nothing is stored in layers) and inject runtime secrets from the platform. Deleting a file in a later layer does not remove it from earlier ones, anddocker historyshows build arguments. - Q: How would you harden a container at runtime?
Non-root numeric user, read-only root filesystem with tmpfs for scratch,--cap-drop ALL,no-new-privileges, resource and pid limits, no--privileged, no docker socket mounts, published ports bound to the interfaces that need them, and regular image scanning. - Q: What do exit codes 137 and 143 mean?
137 is 128 + 9 (SIGKILL): an OOM kill or a stop that exceeded its grace period. 143 is 128 + 15 (SIGTERM): a normal graceful shutdown of a JVM that handled the signal. - Q: Scenario: a container restarts every few minutes in production. How do you investigate?
Check exit code andOOMKilledwithdocker inspect, readdocker logsbefore the restart, look atdocker eventsanddocker statsfor memory growth, compare heap flags to the limit, confirm the health check is not failing too early (start_period), and check for a missing dependency at start-up. Fix the cause, then add a memory dashboard so it is visible next time.
Docker CLI Cheat Sheet and Recap
| Task | Command | Remember |
|---|---|---|
| Build | docker build -t shop:1.4.2 . | -f for another Dockerfile, --target stage, --no-cache --pull for a clean build |
| Build with a secret | docker build --secret id=tok,src=token.txt . | Read in the Dockerfile with RUN --mount=type=secret,id=tok |
| Multi-platform | docker buildx build --platform linux/amd64,linux/arm64 --push -t ... . | Needs a builder that supports it; result is an index |
| Run | docker run -d --name app -p 8080:8080 -m 512m --cpus 1 shop:1.4.2 | --rm to auto-remove, -e KEY=v, --env-file, --init |
| List | docker ps / docker ps -a / docker images / docker volume ls | --format for scripting |
| Logs | docker logs -f --tail 100 app | Needs a log driver that keeps a local copy |
| Shell inside | docker exec -it app sh | Distroless has no shell |
| Inspect | docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' app | The first command after a crash |
| Live usage / events | docker stats / docker events --since 30m | Container total memory, not heap |
| Stop / remove | docker stop app / docker rm -f app | Stop = SIGTERM then SIGKILL after 10 s (-t to change) |
| Copy files | docker cp app:/tmp/heap.hprof . | Works on stopped containers too |
| Layers / size | docker history --no-trunc image / docker system df | History shows ARG values |
| Networks / volumes | docker network create n / docker volume create v | User-defined network = DNS |
| Tag / push | docker tag shop:1.4.2 reg/team/shop:1.4.2 && docker push reg/team/shop:1.4.2 | Version-plus-SHA, immutable |
| Clean up (careful) | docker image prune, docker container prune | Prune acts on the whole daemon; read the prompt |
| Compose | docker compose up -d --wait / logs -f / down (-v deletes data) | config -q validates the file |
- A container is a process with namespaces (view), cgroups (budget) and overlayfs (root). One shared kernel; not a VM, not a hard security boundary.
- Images are stacks of content-addressed layers. Tags move, digests do not;
latestis only a name. Deploy immutable tags or digests. - Order the Dockerfile for the cache: stable and slow first, changing and fast last. Use
.dockerignore, cache mounts and multi-stage builds. - PID 1 must handle SIGTERM. Exec form,
execin scripts or--init; otherwisedocker stoptakes 10 s and ends in 137. - Spring Boot: layered jar via
-Djarmode=tools ... extract --layers, JRE base, non-root numeric user; or buildpacks. Test a jlink runtime by running it. - JVM: set the container limit and
MaxRAMPercentage(about 70-75), remember non-heap memory, know OOM error versus OOMKilled. - Networking and data: user-defined networks for DNS, never
localhostbetween containers, volumes for state, health-gateddepends_onin Compose. - Security: no secrets in layers or
docker history, non-root, read-only root, dropped capabilities, scan images and dependencies, rebuild regularly. - Operate: rotate logs, read the exit code first (137 kill, 143 graceful), then logs, inspect, stats and events.
Verified on 2026-10-31. Run here: Docker Engine 29.4.1 (containerd image store, cgroup v2), Buildx v0.30.1, Compose v2.40.3, Trivy 0.76.1, Spring Boot 3.5.16 sample with Temurin 21, Paketo buildpacks via spring-boot:build-image. Documentation checked: docs.docker.com (Dockerfile reference: HEALTHCHECK options and defaults, RUN --mount types; Compose depends_on conditions; logging drivers and rotation; build best practices) and docs.spring.io Spring Boot 4.1.2 (jarmode tools extract and list-layers, layer order, Dockerfile examples, AOT cache note, build-image default builder). Not run: multi-platform buildx builds, --sbom/--provenance attestations, Docker Scout, pushing to a registry. Image sizes and Trivy counts are one machine on one day; tags such as 21-jre move.
- 01 Linux and Shell Essentials - the processes, signals, permissions and cgroups underneath containers.
- 03 Kubernetes Core - running these images on a cluster: pods, probes, requests and limits.
- 05 CI/CD Pipelines - building, scanning and tagging images automatically.
- JVM Memory and GC and Spring Actuator - the application side of health checks and memory.