Containers are excellent for isolation, but they are not a silver bullet for security. If a hacker breaches your Node.js application, what can they do to the rest of the host machine?
Unfortunately, the default configuration for most Docker images is inherently insecure. By default, applications run as the root user, which violates the Principle of Least Privilege.
In this module, we will explore the Architect-level skills required to harden a container: transitioning to non-root users, managing secrets securely, dropping Linux capabilities, and scanning for vulnerabilities.
The Danger of root in Containers
If you do not specify a user in your Dockerfile, your Node.js application runs as the root user (UID 0).
Because containers share the host's Linux kernel, the root user inside the container is mathematically the exact same root user on the host machine. While Namespaces and cgroups try to contain this user, a container breakout vulnerability could allow the attacker to execute commands as root directly on your host server.
The Solution: The USER Instruction
You should always run your application as a heavily restricted, unprivileged user.
Official Node.js images come with a built-in unprivileged user conveniently named node (UID 1000). You just have to activate it before executing your application:
dockerfile
[!IMPORTANT]
Always place USER node at the very end of your Dockerfile. You still need root permissions to use apk add or to copy files into the container. Switch to node right before the CMD.
Managing Secrets Securely
How do you pass API keys, database passwords, and JWT secrets to your container without leaking them?
The Anti-Pattern: Baking Secrets into Images
dockerfile
If you hardcode secrets in a Dockerfile, anyone who pulls the image or views the source code has your credentials. Even if you try to RUN rm secrets.txt in a later layer, the secret is still permanently embedded in the earlier read-only layer of the OverlayFS.
Method 1: Environment Variables at Runtime
The most common approach is injecting secrets when the container starts.
bash
Or in Compose:
yaml
This is acceptable for most applications, but environment variables can accidentally leak through application crash logs or debugging endpoints (e.g., if a developer logs process.env).
Method 2: Docker Secrets (Filesystem)
For high-security environments, credentials should be injected directly into the container's memory as temporary files.
In Compose, you can define a secret that maps a file on the host to /run/secrets/ in the container.
yaml
Your application reads the file into memory, uses it, and the secret never touches an environment variable.
Dropping Linux Capabilities
Even if a process runs as root, the Linux kernel divides root privileges into distinct "Capabilities."
For example, CAP_CHOWN allows changing file ownership, and CAP_NET_BIND_SERVICE allows binding to privileged ports (like port 80).
By default, Docker drops many dangerous capabilities, but it retains a few to ensure broad compatibility. A truly hardened container drops all capabilities and only adds back the ones strictly required.
bash
In compose.yaml:
yaml
If your Node.js application is just an HTTP server listening on port 3000, it needs exactly zero capabilities to function. --cap-drop=ALL drastically reduces the attack surface if the application is compromised.
Image Scanning and CVEs
You might write perfect code, but if the node:22-alpine base image contains a critical vulnerability in its underlying musl libc implementation, your container is vulnerable.
Docker provides integrated vulnerability scanning to detect Common Vulnerabilities and Exposures (CVEs).
bash
You should integrate tools like docker scout, Trivy, or Snyk into your CI/CD pipeline to block images from being pushed to the registry if high-severity vulnerabilities are detected.
Rootless Docker
As a final note for advanced architectures: traditionally, the Docker daemon itself runs as root on the host machine. If an attacker breaches the Docker socket, they have complete root control over the server.
Rootless Docker is a mode where the Docker daemon and all containers run within a user namespace on the host machine, completely eliminating the need for root privileges. If your organization has strict compliance requirements (SOC2, HIPAA), you will likely be deploying onto Rootless Docker environments.
Key Takeaways
Non-Root Execution: Always use USER node (or equivalent) in your Dockerfile to drop privileges before running your application.
Secrets: Never bake credentials into an image using ENV. Inject them at runtime via -e flags or use Docker Secrets.
Capabilities: Use --cap-drop=ALL to strip away unnecessary kernel privileges, minimizing the impact of a potential breach.
Scanning: Continuously scan your images in CI/CD to catch underlying OS vulnerabilities.
Knowledge Check
Why is it dangerous to embed a database password using the ENV instruction in a Dockerfile?
If you do not specify a USER directive in your Dockerfile, your Node.js application runs as the root user (UID 0) inside the container. What is the primary security risk of this default behavior?
Your Node.js API simply listens on port 3000 to serve HTTP requests. As part of a security hardening effort, you decide to use Linux Capabilities. Which configuration provides the best security posture for this specific workload?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
# syntax=docker/dockerfile:1# This is the final stage of a multi-stage build — see Module 2 for the# earlier "builder" stage that produces the standalone Next.js output.FROM node:22-alpine AS runnerWORKDIR /appENV NODE_ENV=production# Copy built artifacts from the builder stageCOPY--from=builder /app/.next/standalone ./COPY--from=builder /app/.next/static ./.next/static# Switch from root to the 'node' userUSER node# The application now executes with restricted permissionsCMD ["node", "server.js"]
# ❌ NEVER DO THISENV DATABASE_PASSWORD="supersecretpassword"
docker run -eDATABASE_PASSWORD="supersecretpassword" my-api
services:api:environment:DATABASE_PASSWORD: ${DB_PASS}# Read from host's .env file