Running non-root users, managing secrets without leaking them, dropping capabilities, and image scanning.
Introduction
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# syntax=docker/dockerfile:1 FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Copy built artifacts from the builder stage COPY /app/.next/standalone ./ COPY /app/.next/static ./.next/static # Switch from root to the 'node' user USER node # The application now executes with restricted permissions CMD ["node", "server.js"]
[!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# ❌ NEVER DO THIS ENV DATABASE_PASSWORD="supersecretpassword"
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.
bashdocker run -e DATABASE_PASSWORD="supersecretpassword" my-api
Or in Compose:
yamlservices: api: environment: DATABASE_PASSWORD: ${DB_PASS} # Read from host's .env file
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# compose.yaml services: db: image: postgres:15 environment: POSTGRES_PASSWORD_FILE: /run/secrets/db_password secrets: - db_password secrets: db_password: file: ./secrets/db_password.txt
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# Drop all capabilities, and only allow the container to bind to port 80 docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE my-api
In compose.yaml:
yamlservices: api: image: my-api cap_drop: - ALL
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# Scan a local image for vulnerabilities docker scout cves myorg/myapi:latest
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 yourDockerfileto drop privileges before running your application. - Secrets: Never bake credentials into an image using
ENV. Inject them at runtime via-eflags or use Docker Secrets. - Capabilities: Use
--cap-drop=ALLto 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
Question 1: Why is it dangerous to embed a database password using the ENV instruction in a Dockerfile?
- A)
ENVvariables slow down container boot times. - B) Node.js cannot read
ENVvariables natively without an external library. - C) The
ENVinstruction permanently bakes the secret into the image's read-only layer, meaning anyone who can pull the image can extract the password. - D) Environment variables are ignored if the container is run with
--network none.
Reveal Answer
Correct Answer: C
Docker images are composed of immutable layers. Anything written or declared during the build process becomes a permanent part of the image history and can be trivially extracted by anyone with access to the image.
Question 2: 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?
- A) The Node.js application will run slower because root processes have higher scheduling overhead.
- B) 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; a container breakout vulnerability could grant the attacker full root access to the host server.
- C) Docker will refuse to run the container on cloud platforms like AWS or GCP unless it is explicitly marked as privileged.
- D) The container will not be able to write logs to
stdoutbecause the root user's output is blocked by default.
Reveal Answer
Correct Answer: B
Containers isolate processes, but they don't abstract the user identities. UID 0 inside the container is UID 0 on the host. If an attacker finds a vulnerability that allows them to escape the container's Namespaces (a "container breakout"), they emerge onto the host machine with full root privileges. Always use an unprivileged user like USER node to mitigate this risk.
Question 3: 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?
- A) Apply
--cap-add=ALLto ensure Node.js has all the permissions it needs to run efficiently. - B) Apply
--cap-drop=ALLbecause a standard Node.js server listening on an unprivileged port (like 3000) does not require any special kernel capabilities to function. - C) Apply
--cap-drop=NET_BIND_SERVICEto prevent the application from opening any network sockets at all. - D) Apply
--cap-add=SYS_ADMINto allow Node.js to manage its own cgroup resource limits.
Reveal Answer
Correct Answer: B
Linux Capabilities divide the power of "root" into smaller privileges. By default, Docker grants a subset of these capabilities to ensure broad compatibility. However, a basic web server listening on an unprivileged port (any port > 1024) needs zero capabilities to do its job. Dropping all capabilities (--cap-drop=ALL) drastically reduces the attack surface without impacting the application's functionality.
Discussion
0Join the discussion