DevSecOps Zero to Hero: Securing Containers
Welcome to Chapter 4 of the DevSecOps Zero to Hero series. In this chapter, we transition from infrastructure configuration and source code control to securing containers. Containers represent a core building block of modern cloud-native architecture, yet they are frequently deployed with dangerous default settings. This guide explores end-to-end container security hardening—moving from naive, vulnerable Dockerfiles to production-grade, hardened container images and runtimes.
1. The Demo Application: Baseline Setup
To evaluate our security posture, we utilize a lightweight sample Node.js application. This application runs on port 3000 and exposes three core inspection vectors:
- Application Health Response: Returns a confirmation string (
Hello from the container). - User Identity (
UID): Returns the exact user ID running the container process (e.g.,0for root, or a non-zero integer for unprivileged users). - Host Identity: Returns the container's runtime hostname.
Application Source (app.js & package.json)
{
"name": "node-security-demo",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2"
},
"scripts": {
"start": "node app.js"
}
}
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send(`Hello from container!\nHostname: ${process.env.HOSTNAME}\nUID: ${process.getuid ? process.getuid() : 'N/A'}\n`);
});
app.listen(port, () => {
console(`App running on port ${port}`);
});
2. The Naive Approach: How a Junior Engineer Builds Containers
A developer or junior engineer tasked with containerizing this application typically writes a straightforward, functional Dockerfile without security constraints.
# Naive / Insecure Dockerfile
FROM node:25
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]
Building and Running the Naive Image
# Build the insecure image
docker build -t insecure-image .
# Run the container
docker run -d -p 3000:3000 insecure-image
Verifying the application via curl localhost:3000 returns the expected response. However, inspecting the container's internal execution reveals critical vulnerabilities.
3. Core Mental Model: The Root User Trap & Security Challenges
Real-Life Analogy
Analogy: Handing Master Keys to a Stranger. Running a container as the
rootuser is equivalent to giving a visitor the master keys and administrator password to your home or office building. If an intruder gains entry to one room (the container), they possess the keys to compromise the entire physical structure (the host operating system and adjacent systems).
The Mechanics of a Container Breakout
When a container runs as root (UID 0), several structural security risks emerge:
- Unrestricted File System Modification: Attackers can write arbitrary files, consume disk storage, or establish malicious local volumes.
- Denial of Service (DoS) Vectors: A compromised container running as root can spawn hundreds of runaway processes, starving the underlying host machine and adjacent containers of CPU and memory.
- Docker Daemon Exploitation & Host Compromise: Because the Docker daemon (
dockerd) executes asrooton the host, a container process running asrootinside the container can potentially interact with the Docker socket (/var/run/docker.sock) or exploit kernel vulnerabilities to escalate privileges and take over the host machine.
Attacker accesses Container (Running as Root UID 0)
|
| executes malicious process / exploits kernel vulnerability
v
Docker Daemon (dockerd running as root on Host)
|
| gains root access to underlying Host Virtual Machine
v
Full Infrastructure Compromise (Lateral movement across private cloud)
4. Step 1: Running Containers as Non-Root Users
To eliminate container-to-host escalation vectors, we must never run applications as root. We modify our Dockerfile to create and switch to a dedicated, unprivileged system user and group before launching the application.
Secure Dockerfile (Non-Root Implementation)
FROM node:25
WORKDIR /app
# Create a dedicated system user and group
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Copy dependency manifests and source code
COPY package.json .
RUN npm install
COPY . .
# Switch away from root to the unprivileged user
USER appuser
EXPOSE 3000
CMD ["npm", "start"]
Verification of Non-Root Privileges
When we exec into the container and inspect permissions, root privileges are stripped:
docker run -it nonroot-image sh
$ id
uid=999(appuser) gid=999(appuser) groups=999(appuser)
$ apt-get update
E: List directory /var/lib/apt/lists/partial is missing. - Permission denied (13)
$ sudo apt-get install curl
sh: sudo: not found
5. Step 2: Multi-Stage Builds for Minimal Attack Surface
Even as a non-root user, our container image size remains bloated at ~401 MB. Large image sizes indicate the presence of unnecessary packages, compilers, and development tools that expand our attack surface.
Why Image Size Matters for Security
- Increased Vulnerability Density: More installed packages mean a higher statistical probability of unpatched Common Vulnerabilities and Exposures (CVEs).
- Stale Build Artifacts: Dependencies downloaded via
npm installduring compilation often become vulnerable over time (e.g., package version1.0.0develops a critical RCE vulnerability months later).
The Multi-Stage Architecture
Multi-stage builds separate the Build Stage (heavy compilers, dependency managers) from the Run Stage (lightweight runtime environment with only production binaries).
# Stage 1: Build Stage
FROM node:25 AS builder
WORKDIR /build
COPY package.json .
RUN npm install
COPY . .
# Stage 2: Runtime Stage
FROM node:25-slim
WORKDIR /app
# Create unprivileged user in the runtime stage
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Copy only required production artifacts from builder stage
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/app.js .
COPY --from=builder /build/package.json .
USER appuser
EXPOSE 3000
CMD ["node", "app.js"]
- Result: Image size drops dramatically from 401 MB to ~79.5 MB (an ~80% reduction in attack surface).
6. Step 3: Distroless Images (Maximum Hardening)
While node:25-slim removes many unnecessary packages, it still contains a package manager (apt) and basic shell utilities (sh, bash). If an attacker breaches the container, having a shell available makes reconnaissance and exploit staging much easier.
What are Distroless Images?
Distroless images (such as Google Container Registry gcr.io distroless images) contain only the application runtime and its exact runtime dependencies. They contain no package managers (apt, yum), no shells (sh, bash), and no extraneous utilities.
Implementing a Distroless Dockerfile
# Stage 1: Build Stage
FROM node:20 AS builder
WORKDIR /build
COPY package.json .
RUN npm install
COPY . .
# Stage 2: Distroless Runtime Stage
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/app.js .
COPY --from=builder /build/package.json .
EXPOSE 3000
CMD ["app.js"]
- Result: Image size shrinks further to ~52 MB (an ~85% total reduction). Furthermore, running
docker run -it distroless-image shfails completely because no shell binary exists inside the container.
7. Step 4: .dockerignore Hygiene
Just as .gitignore prevents secret and artifact pollution in source control, a .dockerignore file prevents sensitive local files (like .git, local node_modules, environment files, and documentation) from being copied into the container image build context.
Sample .dockerignore File
node_modules
Dockerfile
.git
.gitignore
README.md
*.log
.env
Omitting these files prevents accidental leakage of local development secrets and keeps build contexts optimized and secure.
8. Step 5: Hardening Container Runtime Parameters
Securing the Dockerfile is insufficient if the container runtime executes with insecure default permissions. We must enforce runtime security constraints directly in our docker run commands or orchestrator specifications (Kubernetes security contexts).
Recommended Hardening Flags
Complete Hardened Run Command Example
docker run -d \
--name secured-node-app \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=65536k \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
--pids-limit 100 \
--memory="512m" \
--cpus="1.0" \
-p 3000:3000 \
distroless-image-demo
9. Summary and Key Takeaways
- Never Run as Root: Running containers as
root(UID 0) gives malicious actors a direct escalation path to the host Docker daemon and underlying infrastructure. Always create and switch to an unprivileged system user (USER appuser). - Minimize the Attack Surface with Multi-Stage Builds: Separate compile-time dependencies from runtime requirements using multi-stage Dockerfiles to drop unnecessary build tools.
- Adopt Distroless Images: Utilize distroless base images (e.g., Google Distroless) to eliminate package managers (
apt) and interactive shells (sh/bash) entirely. - Maintain Build Context Hygiene: Use
.dockerignorefiles to prevent sensitive local files, credentials, and bloated development directories from entering the container image build context. - Harden Container Runtimes: Enforce strict runtime security boundaries using
--read-onlyfilesystems, dropped Linux capabilities (--cap-drop=ALL), privilege escalation blocks (--security-opt no-new-privileges), and strict resource limits (--pids-limit,--memory,--cpus).