| |

Docker 17 🐳 Container User Security: USER Instruction and Running as Non-Root

By default, Docker runs every container as the root user (UID 0). This means that if an attacker exploits a vulnerability in your application and manages to break out of the container, they have root-level access on the host machine. Running containers as a non-root user is one of the most effective security measures available, and it is simpler than it might seem. The USER instruction in a Dockerfile determines which user executes commands both during the build and at runtime, and setting it correctly is the difference between a container that is merely isolated and one that is actually secure.

The principle is the same as least privilege on a Linux system: a process should have only the permissions it needs to do its job. A web server does not need to modify system binaries. A database does not need to install packages. A log processor does not need to change the network configuration. When these processes run as root, they have all of those privileges whether they use them or not. When they run as a dedicated non-root user, the blast radius of a compromise is limited to what that user can access .

This chapter covers the USER instruction, the default root behavior, how to create and switch to a non-root user, the UID/GID concepts that make user management consistent, the port-binding limitation for non-root users, the difference between image-level and runtime user configuration, and the patterns for writing Dockerfiles that run securely by default.

Key point: Docker containers run as root by default. The USER instruction switches the build and runtime user to a non-root account. Use USER <name> or USER <uid>:<gid> after all privileged build steps. Non-root users cannot bind to ports below 1024 without CAP_NET_BIND_SERVICE. Create the user with useradd or adduser and set ownership on the application directories before switching.


Why non-root containers matter

The container escape problem. A container is not a perfect security boundary. Kernel vulnerabilities, misconfigured capabilities, and exposed sockets can allow a process to escape the container and reach the host. If the container process is root, the escaped process is root on the host. If the container process is a non-root user, the escaped process has the permissions of that user, which are usually far less dangerous .

The shared kernel problem. Containers share the host kernel. The root user inside a container has UID 0, and UID 0 on the host is the same UID 0. Without user namespaces, a root process in a container is the same root user as the host. Kernel calls made by a containerized root process can affect the host and other containers. Running as non-root reduces the kernel calls that are permitted .

The accidental damage problem. A misconfigured application running as root can delete system files, overwrite binaries, or change critical configuration. The same application running as a non-root user cannot touch files outside its own directories. The principle of least privilege prevents accidents as well as attacks .

The compliance problem. Many security standards and organizational policies prohibit running applications as root. PCI DSS, CIS Benchmarks, and internal security reviews often require evidence that containers run as non-root. Setting USER in the Dockerfile is the simplest way to comply .

The filesystem ownership problem. When a container writes to a mounted volume as root, the files on the host are owned by root. When a non-root user in the container writes to the same volume, the files are owned by that user. The ownership is consistent with the principle of least privilege, and the host does not accumulate root-owned files from containerized applications .


a. The default root behavior

Without a USER instruction, Docker runs the container as root. This is the default for every base image, including alpine, debian, ubuntu, and node. The build process also runs as root unless USER is set.

FROM alpine:3.19
RUN whoami   # root
CMD ["whoami"]  # root

The whoami in the build prints root, and the whoami at runtime prints root. The container has full root privileges unless something changes it.

This default is convenient for build steps that need to install packages or modify system files. It is dangerous for the runtime process, which usually does not need those privileges.

The fix is to add a USER instruction after the privileged steps. The build steps that need root run as root, and the runtime process runs as the non-root user.


b. The USER instruction

The USER instruction sets the user for subsequent RUN, CMD, and ENTRYPOINT instructions. It takes a username or a UID, optionally with a group.

USER appuser
USER 1001
USER 1001:1001
USER appuser:appgroup

The form USER <user>:<group> sets both the user and the primary group. If the group is omitted, the user’s configured groups are used .

The USER instruction persists for the rest of the Dockerfile. If a later instruction needs to run as root, a new USER root switches back, but this is rarely needed and should be minimized .

RUN apt-get update && apt-get install -y curl
USER appuser
CMD ["node", "server.js"]

The apt-get runs as root, and the node server.js runs as appuser. The privileged operation is isolated to the build step, and the runtime process is non-root.


c. Creating a non-root user

The base image usually does not include a user for your application. You create one with useradd (on Debian/Ubuntu) or adduser (on Alpine).

Debian/Ubuntu:

RUN useradd -ms /bin/bash appuser

The -m creates a home directory, and -s /bin/bash sets the shell. For a system user without a home directory or shell, use -r and /sbin/nologin .

Alpine:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

The -S creates a system user or group, which has no password and no aging information. The -G assigns the user to the group .

After creating the user, set ownership on the application directory:

RUN chown -R appuser:appgroup /app

The chown must run as root, before the USER instruction. If the application writes to the directory at runtime, the non-root user needs ownership. If the directory is read-only at runtime, ownership is less critical, but setting it is still the correct practice .

A combined pattern for Alpine:

WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN addgroup -S appgroup && adduser -S appuser -G appgroup \
    && chown -R appuser:appgroup /app
USER appuser
CMD ["node", "src/index.js"]

The dependencies are installed as root, the user is created, the ownership is set, and the runtime switches to the non-root user .


d. UID and GID consistency

Usernames and group names can be changed, and different distributions assign different default IDs to system users. Using numeric UIDs and GIDs ensures that the user is consistently identified even if the container’s /etc/passwd file changes or differs across distributions .

USER 1001:1001

The numeric form is preferred when the container’s user must match a host user for volume mounts. For example, if the host user is UID 1000, and the container writes to a mounted volume, the container’s user should have UID 1000 so the files are owned by the host user .

A common pattern is to create a user with a specific UID that matches the host user:

ARG UID=1000
ARG GID=1000
RUN groupadd -g $GID appgroup \
    && useradd -u $UID -g appgroup -m appuser
USER appuser

The build arguments allow the UID and GID to be overridden at build time, which is useful for development environments where the host UID varies .

When the container runs, the id command confirms the user:

docker run --rm myapp id
# uid=1000(appuser) gid=1000(appgroup)

The numeric IDs are what the kernel uses for permission checks. The names are a convenience for humans .


e. Port binding and capabilities

On Linux, binding to ports below 1024 requires the CAP_NET_BIND_SERVICE capability, which the root user has by default and non-root users do not. A non-root container process cannot bind to port 80 or 443 without additional configuration .

The simplest fix is to use a port above 1024 and map it to the privileged port on the host:

EXPOSE 8080
CMD ["node", "server.js"]
docker run -p 80:8080 myapp

The container listens on 8080, and Docker maps host port 80 to it. The non-root user in the container does not need any special capability .

If the application must bind to a low port, the capability can be added to the binary with setcap:

RUN setcap 'cap_net_bind_service=+ep' /usr/bin/node

This grants the binary the capability without granting the user root. The setcap must run as root before the USER instruction .

Adding the capability at runtime with --cap-add is another option, but it grants the capability to the container process rather than to the specific binary:

docker run --cap-add=NET_BIND_SERVICE myapp

The setcap approach is more precise because it limits the capability to the binary that needs it .


f. Runtime user configuration

The USER instruction in the Dockerfile sets the default user for the image. The runtime can override it with docker run --user:

docker run --user 1001:1001 myapp
docker run --user appuser myapp

The --user flag is useful when the image runs as root by default and the operator wants to enforce non-root execution without rebuilding the image. It is also used in orchestration systems where the security policy is set at the platform level rather than the image level .

In Kubernetes, the equivalent is the securityContext:

spec:
  securityContext:
    runAsUser: 1001
    runAsGroup: 1001
    runAsNonRoot: true

The runAsNonRoot: true field causes the kubelet to reject the container if the image’s default user is root, which enforces the policy even if the image does not set USER .

For development, running as the host user avoids the file ownership mismatch on mounted volumes:

docker run --rm -v $(pwd):/app -u $(id -u):$(id -g) myapp

The container writes files owned by the host user, which prevents the permission errors that occur when root creates files the host user cannot modify .


Complete Example Session

# ============================================
# PART 1: DEFAULT ROOT BEHAVIOR
# ============================================
FROM alpine:3.19
RUN whoami
CMD ["whoami"]
# ============================================
# PART 2: CREATE A USER
# ============================================
FROM alpine:3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
RUN whoami
# ============================================
# PART 3: SET OWNERSHIP BEFORE SWITCHING
# ============================================
FROM alpine:3.19
WORKDIR /app
COPY . .
RUN addgroup -S appgroup && adduser -S appuser -G appgroup \
    && chown -R appuser:appgroup /app
USER appuser
CMD ["./run.sh"]
# ============================================
# PART 4: DEBIAN USER CREATION
# ============================================
FROM debian:12-slim
RUN useradd -ms /bin/bash appuser
WORKDIR /app
RUN chown -R appuser:appuser /app
USER appuser
CMD ["bash"]
# ============================================
# PART 5: NUMERIC UID AND GID
# ============================================
FROM alpine:3.19
USER 1001:1001
CMD ["id"]
# ============================================
# PART 6: ARG FOR UID AND GID
# ============================================
FROM alpine:3.19
ARG UID=1000
ARG GID=1000
RUN addgroup -g $GID appgroup && adduser -u $UID -G appgroup -S appuser
USER appuser
# ============================================
# PART 7: PORT ABOVE 1024
# ============================================
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN addgroup -S appgroup && adduser -S appuser -G appgroup \
    && chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
CMD ["node", "server.js"]
# ============================================
# PART 8: SETCAP FOR LOW PORT
# ============================================
FROM node:20-alpine
RUN apk add --no-cache libcap \
    && setcap 'cap_net_bind_service=+ep' /usr/bin/node
USER appuser
CMD ["node", "server.js"]
# ============================================
# PART 9: RUNTIME USER OVERRIDE
# ============================================
# docker run --user 1001:1001 myapp
# docker run -u $(id -u):$(id -g) -v $(pwd):/app myapp
# ============================================
# PART 10: KUBERNETES SECURITYCONTEXT
# ============================================
apiVersion: v1
kind: Pod
metadata:
  name: myapp
spec:
  securityContext:
    runAsUser: 1001
    runAsGroup: 1001
    runAsNonRoot: true
  containers:
  - name: myapp
    image: myapp:latest

These ten parts cover the default root behavior, creating a user, setting ownership, Debian user creation, numeric UID and GID, build arguments for UID and GID, using a port above 1024, using setcap for a low port, runtime user override, and the Kubernetes securityContext.


Quick Reference

USER Instruction

FormMeaning
USER appuserSet the user
USER appuser:appgroupSet user and primary group
USER 1001Set by UID
USER 1001:1001Set by UID and GID

User Creation Commands

DistributionCommand
Debian/Ubuntuuseradd -ms /bin/bash appuser
Alpineaddgroup -S appgroup && adduser -S appuser -G appgroup
Debian system useruseradd -r -s /sbin/nologin appuser

Ownership

CommandPurpose
chown -R user:group /appSet ownership recursively
chown user:group /dataSet ownership on a directory
COPY --chown=user:group . .Set ownership during copy

Port Binding

ApproachDetail
Port above 1024EXPOSE 8080 and map with -p 80:8080
setcapsetcap 'cap_net_bind_service=+ep' /usr/bin/node
--cap-adddocker run --cap-add=NET_BIND_SERVICE myapp

Runtime Override

CommandPurpose
docker run --user 1001:1001Set the runtime user
docker run -u $(id -u):$(id -g)Match the host user
Kubernetes runAsNonRoot: trueReject root containers

Best Practices

✅ Do This:

# Create the user and set ownership before switching
RUN addgroup -S appgroup && adduser -S appuser -G appgroup \
    && chown -R appuser:appgroup /app
USER appuser

# Use numeric UIDs for consistency
USER 1001:1001

# Use a port above 1024
EXPOSE 8080

# Use setcap for a low port
RUN setcap 'cap_net_bind_service=+ep' /usr/bin/node

❌ Don’t Do This:

# Run as root by default
# No USER instruction  # ❌

# Forget to chown the application directory
RUN useradd appuser
USER appuser  # ❌ appuser cannot write to /app

# Bind to port 80 as a non-root user
EXPOSE 80  # ❌ requires CAP_NET_BIND_SERVICE

# Use sudo in the container
RUN echo "appuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers  # ❌ defeats the purpose

Common Pitfalls

PitfallWhy It HappensFix
Permission denied writing filesDirectory not owned by the userchown -R user:group /app
Cannot bind to port 80Non-root user lacks the capabilityUse a high port or setcap
User not foundUser not created before USERCreate the user with useradd
Volume mounts owned by rootContainer wrote as rootRun as non-root or chown the volume
UID mismatch with hostDifferent UIDs on host and containerUse --user $(id -u):$(id -g)
Kubernetes rejects containerrunAsNonRoot and image runs as rootSet USER in the Dockerfile

Real-World Examples

1. Alpine Non-Root

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

2. Debian Non-Root

RUN useradd -ms /bin/bash appuser
USER appuser

3. Numeric USER

USER 1001:1001

4. Chown Before USER

RUN chown -R appuser:appgroup /app
USER appuser

5. High Port

EXPOSE 8080

6. Setcap for Low Port

RUN setcap 'cap_net_bind_service=+ep' /usr/bin/node

7. COPY with Ownership

COPY --chown=appuser:appgroup . .

8. Runtime User Override

docker run --user 1001:1001 myapp

9. Host User Match

docker run -u $(id -u):$(id -g) -v $(pwd):/app myapp

10. Kubernetes runAsNonRoot

securityContext:
  runAsNonRoot: true
  runAsUser: 1001

Visual

Default Root vs Non-Root

┌──────────────────────────────────────────────────────────────┐
│  DEFAULT:                                                    │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Container process: root (UID 0)                       │  │
│  │  Host: root (UID 0)                                    │  │
│  │  If escaped: full host access                          │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  NON-ROOT:                                                   │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Container process: appuser (UID 1001)                 │  │
│  │  Host: appuser (UID 1001)                              │  │
│  │  If escaped: limited to appuser's permissions          │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

USER Instruction Flow

┌──────────────────────────────────────────────────────────────┐
│  FROM alpine:3.19                                            │
│       │                                                      │
│       ▼                                                      │
│  RUN apt-get update && apt-get install -y curl  ← root       │
│       │                                                      │
│       ▼                                                      │
│  RUN addgroup -S appgroup && adduser -S appuser -G appgroup  │
│       │                                                      │
│       ▼                                                      │
│  RUN chown -R appuser:appgroup /app  ← root                  │
│       │                                                      │
│       ▼                                                      │
│  USER appuser  ← switch to non-root                          │
│       │                                                      │
│       ▼                                                      │
│  CMD ["node", "server.js"]  ← runs as appuser                │
└──────────────────────────────────────────────────────────────┘

Port Binding

┌──────────────────────────────────────────────────────────────┐
│  NON-ROOT CANNOT BIND TO PORT 80                             │
│                                                              │
│  Option 1: Use a port above 1024                             │
│  └── EXPOSE 8080, map with -p 80:8080                        │
│                                                              │
│  Option 2: Grant the capability to the binary                │
│  └── setcap 'cap_net_bind_service=+ep' /usr/bin/node         │
│                                                              │
│  Option 3: Add the capability at runtime                     │
│  └── docker run --cap-add=NET_BIND_SERVICE myapp             │
└──────────────────────────────────────────────────────────────┘

Volume Ownership

┌──────────────────────────────────────────────────────────────┐
│  CONTAINER AS ROOT:                                          │
│  └── Writes to /data are owned by root on the host.          │
│      The host user may not be able to modify them.           │
│                                                              │
│  CONTAINER AS NON-ROOT (UID 1001):                           │
│  └── Writes to /data are owned by UID 1001 on the host.      │
│      The host user with UID 1001 can modify them.            │
│                                                              │
│  Match the container UID to the host UID for consistency.    │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Default userroot (UID 0)
USER instructionSets build and runtime user
User creation (Debian)useradd -ms /bin/bash appuser
User creation (Alpine)adduser -S appuser -G appgroup
Ownershipchown -R appuser:appgroup /app
Numeric IDsUSER 1001:1001
Low port bindingRequires CAP_NET_BIND_SERVICE
High port workaroundEXPOSE 8080, map with -p 80:8080
setcapsetcap 'cap_net_bind_service=+ep' /bin
Runtime overridedocker run --user 1001:1001
KubernetessecurityContext.runAsNonRoot: true

Key takeaways:

  • Docker containers run as root by default. Without a USER instruction, the build and runtime processes run as UID 0, which means a container escape becomes a host root compromise .
  • The USER instruction switches the user. It applies to all subsequent RUN, CMD, and ENTRYPOINT instructions. Place it after the privileged build steps and before the runtime command.
  • Create the user and set ownership before switching. The useradd or adduser and the chown must run as root. The USER instruction comes after them .
  • Use numeric UIDs for consistency. Usernames can change, and different distributions assign different IDs. A numeric UID ensures the same identity across images and matches the host user for volume mounts .
  • Non-root users cannot bind to ports below 1024. Use a port above 1024 and map it, or grant the capability with setcap .
  • The runtime can override the user. docker run --user and the Kubernetes securityContext set the user at the platform level, which enforces the policy even if the image runs as root by default .
  • Match the container UID to the host UID for volumes. When a container writes to a mounted volume, the file ownership is the container user’s UID. Matching the UIDs prevents permission errors .

Remember: Running containers as non-root is one of the simplest and most effective security measures available. The default is root, and the default is dangerous. The USER instruction switches the container to a non-root account, and the pattern is consistent: create the user, set ownership on the application directories, and switch before the runtime command. Use numeric UIDs for consistency, use high ports or setcap for port binding, and override the user at runtime when the platform requires it. The principle is the same as on any Linux system: a process should have only the permissions it needs.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!