Run containers as non-root user
Learn to configure Docker containers to run as a non-root user for improved security. This lesson covers the core concept, step-by-step implementation, and practical exercises to apply the principle in your Dockerfiles.
Focus: run docker containers as non-root user
Running Docker containers as root by default is a common security risk. The container process has full privileges inside the container, and if an attacker breaks out, they gain root access to the host. This lesson shows you how to eliminate that risk by configuring your Docker images to run as a non-root user.
The problem this lesson solves
By default, Docker containers run as the root user (UID 0) inside the container. While this works for many applications, it creates a major security vulnerability:
- If an attacker compromises a root-running process, they may escape the container and gain root on the host.
- Root inside the container can modify system files, install software, or change permissions — all without restriction.
- Many production security policies (e.g., SOC 2, HIPAA) require containers to run with least privilege.
Pro tip: Even in development, running as root can mask permission issues that will bite you in production.
Core concept / mental model
Think of the container's internal user like a user on a Linux machine. If you give that user sudo access and they run a script that deletes everything under /, there's no safety net. Running as non-root means the process can only touch what it owns.
Key definitions:
- UID (User ID): A numeric identifier for a user. 0 is root; anything else is a regular user.
- GID (Group ID): A numeric identifier for a group. Matches file permissions.
- USER instruction: Dockerfile directive that sets the user to run subsequent RUN, CMD, and ENTRYPOINT instructions.
Mental model: Imagine you're renting a shared office. Running as root is like having keys to every drawer and server in the building. Running as non-root is like having a key only to your own desk and cabinet.
How it works step by step
-
Decide on a UID/GID. Common choices:
1000(first human user on most Linux systems),1001, or a dedicated app user likenodeorappuser. If you're building an app for a team, choose a consistent UID across environments. -
Create the user in your Dockerfile using
RUN useradd -m -u <UID> <username>orRUN adduser -D -u <UID> <username>. The-mflag creates a home directory. -
Set ownership of application files to that user using
RUN chown -R <username>:<username> /appor similar. -
Add a
USERinstruction near the end of your Dockerfile, before theCMDorENTRYPOINT. This switches the runtime user. -
Test that the container can write to any persistent volumes or tmp directories (e.g.,
/tmp,/data).
Pro tip: Always place
USERafter copying files but before the final command. This ensures the build still runs as root (needed for installing packages) but runs as non-root at runtime.
Hands-on walkthrough
Let's create a simple Node.js app that writes logs to a file. The default Docker image runs as root. We'll modify it to run as the node user.
Step 1: Initial Dockerfile (runs as root)
FROM node:18-alpine
WORKDIR /app
# Set up a simple app that writes to stdout
COPY app.js .
CMD ["node", "app.js"]
Now create app.js:
const fs = require('fs');
fs.writeFile('/tmp/app.log', 'Hello from non-root!\n', (err) => {
if (err) {
console.error('Write failed:', err);
process.exit(1);
}
console.log('Log written successfully');
});
Build and run:
docker build -t root-app .
docker run --rm root-app
Output:
Log written successfully
The node user that runs a container started from the node:18-alpine image is actually the node user (UID 1000), but the image itself runs as root during build. The base image already creates a node user, so we can switch to it.
Step 2: Switch to non-root user
Update the Dockerfile:
FROM node:18-alpine
WORKDIR /app
# Copy files first (still as root, needed for installation)
COPY package*.json ./
RUN npm ci --production
COPY . .
# Switch to the node user (UID 1000)
USER node
# Ensure the app can write to tmp directory (it already can in this case)
# For production, you may need to create a volume or use /tmp explicitly
CMD ["node", "app.js"]
Build and run:
docker build -t nonroot-app .
docker run --rm nonroot-app
Output:
Log written successfully
But what if the app needs to write to a location the node user doesn't own? For example, if we wanted to write to /var/log/app.log inside the container. We'd need to change ownership first.
Step 3: Handling file ownership
FROM node:18-alpine
RUN mkdir -p /var/log/app && \
chown -R node:node /var/log/app
WORKDIR /app
COPY . .
USER node
# Update app.js to write to /var/log/app/app.log
CMD ["node", "app.js"]
Update app.js:
const fs = require('fs');
fs.writeFile('/var/log/app/app.log', 'Hello from non-root!\n', (err) => {
if (err) {
console.error('Write failed:', err);
process.exit(1);
}
console.log('Log written successfully');
});
Now build and run:
docker build -t nonroot-app-log .
docker run --rm nonroot-app-log
Output:
Log written successfully
This works because we gave the node user ownership of the target directory during build time.
Compare options / when to choose what
| Approach | Best for | Trade-off |
|---|---|---|
Using base image's built-in user (e.g., node, nginx) |
Quick setup, known base images | User UID may vary; must verify file ownership |
Creating a custom user (e.g., appuser) |
Full control over UID/GID, consistent across environments | Adds one more build layer; you must manage permissions |
Using --user flag at runtime |
Temporary overrides, debugging | Not repeatable; image documentation may be inconsistent |
Pro tip: For production, prefer custom user with fixed UID (e.g., 1001) that matches host user if you map volumes. This avoids permission mismatch errors on bind mounts.
Troubleshooting & edge cases
Permission denied when writing to mounted volumes
If you mount a host directory (-v /host/path:/app/data) and the container expects to write there as node, the host directory may be owned by root. Solution:
- Use
docker run --userwith a UID that matches the host user (e.g.,--user 1000:1000). - Or create the directory on the host with the correct permissions before running the container.
"useradd: user 'node' already exists"
This happens if you try to create a user with a name that already exists in the base image. Always check the base image's documentation or check /etc/passwd.
App cannot bind to ports below 1024
Non-root users cannot bind to privileged ports (<1024). If your app needs port 80 or 443, either:
- Map a higher port inside the container (e.g.,
docker run -p 8080:8080and let the app listen on 8080). - Or use
setcapto grant the ability to bind to privileged ports (not recommended for simplicity).
Root processes inside the container (e.g., cron, init)
Some containers run a process manager like supervisord as root, which then spawns child processes as non-root. This is acceptable if the parent process is minimal and the children are non-root. However, this adds attack surface; prefer single-process non-root containers.
What you learned & what's next
You now understand:
- Why running containers as root is dangerous.
- How to use the
USERinstruction in Dockerfiles. - How to manage file permissions for non-root users.
- When to use a custom user vs. a built-in user.
- How to handle common edge cases like port binding and volume mounting.
Next lesson: You'll learn how to scan container images for vulnerabilities using docker scout, building on the security foundation you've just established.
Practice: Create a Dockerfile for a simple Python web server that writes to a log file, ensuring it runs as a non-root user named webuser with UID 1001. Test by building and running the container, then verify the user inside with docker exec <container> whoami.
Practice recap
Create a Dockerfile for a simple Python web server that writes to a log file, ensuring it runs as a non-root user named webuser with UID 1001. Build and run the container, then verify the user inside with docker exec <container> whoami. As a challenge, mount a host directory and ensure the container can write to it without permission errors.
Common mistakes
- Using
USER rootat the end of the Dockerfile, which overrides any earlier non-root settings — always placeUSERafter the finalRUNbut beforeCMD. - Forgetting to change ownership of application files and mounted volumes to the non-root user — causes 'Permission denied' errors at runtime.
- Creating a new user but not providing a home directory (omit
-m) — may cause issues with tools that expect a writable home directory (e.g., npm, pip). - Assuming base images automatically use a non-root user — many (e.g.,
ubuntu,debian) default to root unless you switch.
Variations
- Using
gosuorsu-execin an entrypoint script to drop privileges at runtime, instead of using the DockerfileUSERinstruction — allows root-first startup for init tasks. - Using
podmanorrootless Dockerto run the entire container engine without root on the host — complements but does not replace container-level non-root user. - Using a multi-stage build where the final stage uses a
scratchbase image with only the binary and a dedicated non-root user.
Real-world use cases
- A Node.js API server running as
nodeuser to prevent a compromised endpoint from modifying system binaries. - A data pipeline that writes processed files to a mounted NFS volume, requiring UID matching between container and host.
- A WordPress container configured to run as
www-datato limit file-system damage from a plugin exploit.
Key takeaways
- Always use the
USERinstruction in Dockerfiles to run containers with least privilege. - Non-root users cannot bind to ports <1024; either expose a higher port or use
setcap. - File ownership must be transferred to the non-root user during the build, not at runtime.
- Prefer a custom user with a fixed UID for consistency across environments and volumes.
- Test your non-root setup by intentionally trying to write to a system directory to verify failure.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.