Securing containers in layers: the dangers of root and large base images, non-root USER, read-only filesystem with tmpfs, capability restrictions, resource limits, no-new-privileges, seccomp and AppArmor, avoiding --privileged, all the way to Rootless Docker with ready-to-use examples.

After mastering the image distribution path in episode 12 — registries, push, pull, digests — in this episode we turn to the question that should be asked before that image ever runs: is the container we're about to create safe? It's time to be honest: most production containers in the real world run as the root user, on a full base image carrying hundreds of unused packages, with no resource limits, and with lingering credentials. Not because people are negligent — but because Docker's defaults point that way.
The most dangerous misconception in the container world is the assumption that a container = a lightweight virtual machine. This assumption is wrong and dangerous: a virtual machine has its own kernel, while a container shares the host kernel. When a process inside a container succeeds in exploiting a kernel weakness, it has touched the foundation of the entire host — and the boundary that usually separates "inside the container" from "outside the container" is just a few isolation mechanisms that careless configuration can loosen.
In this episode we'll build a layered defense: understand the default risks (root + large base image), run processes as a non-root user, make the filesystem read-only with tmpfs, restrict Linux capabilities to only what's genuinely needed, set resource limits, enable no-new-privileges, seccomp, and AppArmor, avoid --privileged, close with Rootless Docker, and summarize it in ready-to-use docker run and Dockerfile examples.
Before discussing flags, let's agree on the security model. Containers are isolated by namespaces (processes, network, mount, and others — remember episode 1) and limited by cgroups, but neither isolates the kernel. The same kernel serves the host and all its containers. The analogy: a virtual machine is an apartment with concrete walls and an iron door; a container is a room in one house with a wooden door — the door exists, but the walls are shared.
The practical implication: container security isn't built at a single point, but in layers — each layer narrows what a process inside the container can do, so if one layer breaks, there are still other layers. Docker's default configuration actually already has several layers (default seccomp, a limited capability list), but those layers are still far from what production expects. Let's tighten them one by one.
The two defaults you must change immediately are the root user and the bloated base image.
Running as root. By default, processes inside a container run as root (UID 0) — and root inside the container is root on the host, unless remapped (we cover this in Rootless). If your application is compromised, the attacker immediately gets the most powerful account in the box: can write anywhere, install tools, and probe for escape paths. The principle is simple: the most powerful process should never be the attack target.
Large base images. The bigger the image, the wider its attack surface — every unused package is code that could be exploited. Full images (ubuntu:24.04 full, node:20 complete) carry compilers, toolchains, and utilities the runtime doesn't need. This is why the episode 7 lesson (multi-stage builds) and choosing a lean base (alpine, slim, distroless, even scratch) is a security foundation — not just a size optimization.
| Base Image | Typical Size | Extra Packages | Suitable for |
|---|---|---|---|
ubuntu:24.04 | ~78 MB | Many (build tools, etc.) | Build stage |
node:20-alpine | ~190 MB | Minimal (apk) | Node.js runtime |
gcr.io/distroless/nodejs20-debian12 | ~70 MB | No shell/package manager | Production runtime |
scratch | 0 MB | Nothing at all | Static binaries (Go) |
Important
"A small image" doesn't mean automatically safe — but a small image trims the amount of code that might be vulnerable. The more important rule: the base image must have a known origin, be pinned to a version/digest, and be updated regularly. A "unique" image pulled from an unknown user and never updated is the biggest risk — we cover scanning in episode 14.
The first and most impactful step: run the process as a non-root user. In the Dockerfile, use the USER instruction — and create the user yourself rather than guessing at one that may not exist:
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . /app
RUN npm ci --omit=dev
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]Note a detail that's often missed: COPY --chown=appuser:appgroup ensures the files in the image are owned by the non-root user. Without it, the files are owned by root and the non-root process can only read — which will produce weird errors when the application tries to write. The order is also deliberate: USER is placed after all the RUN commands that need root rights (installing dependencies), so no command "arrives late" because of the wrong user.
Why is this so important? Because of the blast radius. As non-root, an attacker who compromises the application can't write to system directories, can't install packages, can't modify application binaries — they're confined to the rights the application was meant to have.
The next layer: make the root filesystem unwritable. A healthy container shouldn't write to the container layer — all persistent state must live on volumes (episode 8), and temporary data on tmpfs. With --read-only, every attempt to write to the root filesystem fails:
docker run -d --name app \
--read-only \
--tmpfs /tmp:rw,size=64m \
--tmpfs /var/run \
my-app:1.0Real practice: many applications write to /tmp (buffers, temporary uploads, pid files). --tmpfs /tmp provides writable space in RAM — fast and automatically gone when the container stops. Exactly the episode 8 lesson: tmpfs for temporary data, volumes for data that must survive.
In Compose, the form is:
services:
api:
image: my-app:1.0
read_only: true
tmpfs:
- /tmp:rw,size=64m
security_opt:
- no-new-privileges:trueIf your application fails with "read-only file system", don't rush to remove --read-only — find out what is writing and where. Giving tmpfs for one real directory (e.g. /tmp or a cache directory) usually solves the problem without sacrificing this security layer.
On Linux, root isn't one monolithic power — it's divided into dozens of granular capabilities (specific abilities like NET_BIND_SERVICE for binding ports < 1024, CAP_DAC_OVERRIDE for bypassing file permissions, CAP_SYS_ADMIN for super-user system operations). A root container is only as strong as the set of capabilities it has. Docker gives containers a default capability set that's still too broad for most applications.
Best practice: drop everything, then add back only what's truly needed:
docker run -d --name web \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
-p 80:80 \
nginxNET_BIND_SERVICE is the only capability a web server needs to bind ports 80/443 (ports below 1024). An ordinary application listening on ports > 1024 doesn't need any capability at all — plain --cap-drop=ALL suffices.
Warning
Beware of tempting --cap-add values: CAP_SYS_ADMIN (a shortcut to many root rights), CAP_NET_ADMIN (changing network configuration), CAP_SYS_PTRACE (debugging other processes). This kind of need almost always signals a wrong design — and the habit of adding capabilities "to make it work" is the fastest way to destroy isolation.
A healthy container can turn into a host-killer: unlimited memory causes OOM across the whole node, wasteful CPU chokes other processes, and repeated forks (fork bombs) saturate the process count. Three limits are a must:
docker run -d --name app \
--memory="512m" \
--cpus="1.5" \
--pids-limit=200 \
my-app:1.0--memory="512m" — memory limit; a container exceeding it is killed by the OOM killer (cgroups).--cpus="1.5" — the container uses at most 1.5 cores.--pids-limit=200 — at most 200 processes in the container; this stops a fork bomb before it exhausts host PIDs.In Compose, use deploy.resources (part of the Compose Specification):
services:
api:
image: my-app:1.0
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 128Mlimits are hard ceilings, reservations are minimum guarantees. A good exercise: run docker stats on a stable container for a few days, then set limits slightly above the peak usage — enough room to breathe, but not enough to harm your neighbors.
The next three layers are nearly "free" — enabling them doesn't demand code changes:
no-new-privileges — forbids processes inside the container from raising their rights (e.g. via a setuid binary). One flag: --security-opt=no-new-privileges:true. Minimal effect for normal applications, but it closes one of the most classic privilege escalation paths.--security-opt seccomp=/path/profile.json), but for most applications, the default profile is sufficient — blocking more could actually break the application.docker-default profile automatically; you can replace it with your own profile.docker run -d --name app \
--security-opt=no-new-privileges:true \
--security-opt=seccomp=default \
--security-opt=apparmor=docker-default \
my-app:1.0The key point: all three work without you noticing — until you loosen them. And the surest way to loosen all of them at once is --privileged.
--privileged is the nuclear button: the container gets all capabilities, access to all host devices, and seccomp is turned off. In practice, a --privileged container is host root in everything but name. The reason you "need" this flag is almost always because you want to use something that could be granted more specifically:
A need often "solved" with --privileged | The correct alternative |
|---|---|
| Access to one host device | --device=/dev/sda1:/dev/sda1 |
| Managing certain kernel modules | --cap-add=SYS_MODULE (still dangerous — reconsider) |
| Docker inside a container (DinD) | Use a safe CI approach (episode 19) |
| Kernel logs | --cap-add=SYS_ADMIN... once again, reconsider |
The rule of thumb: if you don't know exactly why you need --privileged, then you don't need it. Every use of --privileged must be justified in code review — because it destroys nearly all the layers we build in this episode at once.
Let's combine everything into a single command you can use as a pattern:
docker run -d --name app \
--read-only \
--tmpfs /tmp:rw,size=64m \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges:true \
--security-opt=seccomp=default \
--security-opt=apparmor=docker-default \
--memory="512m" --cpus="1.5" --pids-limit=200 \
--restart=unless-stopped \
-p 3000:3000 \
my-app:1.0Every flag has a reason: --read-only + --tmpfs restrict writes, --cap-drop=ALL trims root power, --cap-add=NET_BIND_SERVICE returns only what's needed, the three --security-opt flags close privilege escalation paths, the three limits restrain resources, and --restart=unless-stopped recovers the container after a crash. This isn't a "paranoid configuration" — it's a production baseline.
On the build side, the combination of multi-stage + non-root + lean image produces a much smaller and safer runtime:
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
USER nonroot
ENV NODE_ENV=production
EXPOSE 3000
CMD ["dist/server.js"]Note: the build stage uses Alpine with all its tools; the runtime stage uses distroless — no shell, no package manager, no unnecessary binaries. USER nonroot exists in the distroless image by default. The result: a small runtime image, no shell (making post-compromise exploitation harder), running without root.
Tip
Don't forget HEALTHCHECK (episode 15) in a production Dockerfile — and mind the tautology: on a distroless image, curl and wget may not exist. Use a tool available in the image, or call the endpoint from the application itself. A healthcheck that "fails" because its binary doesn't exist will make a healthy container look dead.
The last and most thorough layer: run the entire Docker daemon without root. In Rootless mode, dockerd runs as an ordinary user, using user namespaces and slirp4netns/rootlesskit for networking. The impact is transformative: because the daemon itself isn't root, there is no root process on the host at all — even root inside a container is mapped to a non-privileged user on the host.
Its advantages:
docker group (which we mentioned in episode 0) is gone.root.Its limitations (must be known before choosing):
systemd often makes it easier).How to enable it depends on the distribution (usually dockerd-rootless-setuptool.sh install after installing the docker-ce-rootless-extras package). For a shared development machine, a personal laptop, or a host you can't fully trust, Rootless Docker is a major security leap at a reasonable cost.
--privileged as the "first solution". Destroys all layers at once. Always ask: which device really needs access?
Non-root that still fails to write. Files in the image are owned by root; the non-root process can't write. The solution is COPY --chown, not retreating to root.
Removing --read-only the moment the application errors. Read the error: find the directory being written, add --tmpfs or a volume for it.
A large base image without a reason. Move to multi-stage + slim/alpine/distroless — and remember, shrinking an image without updating the base image isn't security.
--cap-add stacking up. Every extra capability is a door. If three different capabilities have been added, stop and evaluate the design.
Ignoring custom seccomp when needed. The default profile blocks some syscalls that might be needed by certain applications. If an application crashes mysteriously, dmesg often shows the syscall blocked by seccomp — audit before blaming seccomp.
Rootless on an unsupported host. Check cgroups v2 support before investing; on old hosts, rootless mode may not work fully.
In this episode 13 we've built a layered defense for containers: understanding that containers share the kernel so isolation must be actively tightened, lowering the default risks (root + large base image), running processes as a non-root user with USER and COPY --chown, restricting writes with --read-only + tmpfs, trimming power via --cap-drop=ALL --cap-add=NET_BIND_SERVICE, restraining resources with --memory, --cpus, --pids-limit, closing privilege escalation with no-new-privileges, seccomp, and AppArmor, avoiding --privileged (with more specific alternatives), summarizing it in hardened docker run and Dockerfile examples, and understanding Rootless Docker as the most thorough layer.
Core takeaways:
--privileged is the nuclear button — there's almost always a more specific alternative.Now your containers are hard to penetrate. But there's one unanswered question: how do you know that the image you're running — including the base image inside it — is genuinely free of known vulnerabilities? Hardening limits the damage, but doesn't tell you what you're running. In the next episode, episode 14, we'll move to Vulnerability Scanning & Image Integrity: scanning images with Docker Scout, Trivy, and Grype, understanding SBOMs, signing and verifying images with Docker Content Trust and Cosign/Sigstore, and building a proper remediation strategy. See you in episode 14!