Container-safe Linux Users: A Complete Guide

Introduction: Why Container Security Matters

Imagine you’re building a house. You wouldn’t give every visitor the master key to every room, would you? The same principle applies to containers. When we run containers as the root user (the “master key” of Linux), we’re essentially giving our containerized applications unlimited power over the system. This creates a massive security vulnerability that attackers can exploit.

In this guide, we’ll explore how to create and use non-root users in containers, transforming your applications from security liabilities into well-protected services.

Understanding the Root Problem

What is the Root User?

The root user in Linux is like the administrator account on Windows – it has complete control over the system. It can:

  • Read, write, and delete any file
  • Install or remove software
  • Modify system configurations
  • Access any network port
  • Kill any process

Why Running Containers as Root is Dangerous

When a container runs as root, several risks emerge:

  1. Container Escape: If an attacker breaks out of the container, they have root access to the host system
  2. Privilege Escalation: Malicious code can perform system-level operations
  3. File System Access: Root can access and modify critical system files
  4. Network Exploitation: Root can bind to privileged ports (0-1023)

Let’s see this in action. Consider this vulnerable Dockerfile:

1
2
3
4
# BAD EXAMPLE - Don't do this!
FROM ubuntu:20.04
COPY app.py /app/
CMD ["python3", "/app/app.py"]

When this container runs, it executes as root by default. If app.py has a vulnerability, an attacker could potentially:

  • Access the host’s file system
  • Install malware
  • Launch attacks on other systems

Creating Non-Root Users: The Safe Approach

Method 1: Using adduser Command

The adduser command is the most straightforward way to create users. Here’s how it works:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
FROM ubuntu:20.04

# Create a new user called 'appuser'
RUN adduser --disabled-password --gecos '' appuser

# Copy your application
COPY app.py /app/

# Change ownership of the app directory to appuser
RUN chown -R appuser:appuser /app

# Switch to the non-root user
USER appuser

# Set working directory
WORKDIR /app

# Run the application
CMD ["python3", "app.py"]

Breaking down this Dockerfile:

  • adduser --disabled-password --gecos '' appuser: Creates a user named ‘appuser’ without a password and without prompting for user information
  • chown -R appuser:appuser /app: Changes the ownership of the /app directory and all its contents to the appuser
  • USER appuser: Switches the current user context to appuser for all subsequent commands

Method 2: Using useradd Command (More Control)

The useradd command gives you more granular control:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
FROM ubuntu:20.04

# Create a system user with specific UID and GID
RUN useradd --create-home --shell /bin/bash --user-group --uid 1001 appuser

# Copy application files
COPY app.py /home/appuser/

# Set proper ownership
RUN chown -R appuser:appuser /home/appuser

# Switch to non-root user
USER appuser

# Set working directory
WORKDIR /home/appuser

CMD ["python3", "app.py"]

Command breakdown:

  • --create-home: Creates a home directory for the user
  • --shell /bin/bash: Sets the default shell
  • --user-group: Creates a group with the same name as the user
  • --uid 1001: Assigns a specific user ID (useful for consistency across environments)

Method 3: Creating Users Without Home Directories (Minimal Approach)

For applications that don’t need a home directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
FROM alpine:3.17

# Alpine uses adduser with different syntax
RUN adduser -D -s /bin/sh appuser

# Copy application
COPY app.py /app/

# Set ownership
RUN chown appuser:appuser /app

USER appuser
WORKDIR /app

CMD ["python3", "app.py"]

Alpine-specific notes:

  • -D: Creates user without password
  • -s /bin/sh: Sets the shell (Alpine uses sh by default)

Practical Examples: Real-World Applications

Example 1: Python Web Application

Let’s create a secure Flask application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
FROM python:3.9-slim

# Install dependencies first (better caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Create non-root user
RUN adduser --disabled-password --gecos '' flaskuser

# Create application directory
RUN mkdir /app && chown flaskuser:flaskuser /app

# Copy application code
COPY --chown=flaskuser:flaskuser . /app/

# Switch to non-root user
USER flaskuser

# Set working directory
WORKDIR /app

# Expose non-privileged port
EXPOSE 8080

# Run application
CMD ["python", "app.py"]

Key security features:

  • Uses --chown flag in COPY to set ownership during copy
  • Exposes port 8080 (non-privileged) instead of 80
  • Installs dependencies as root, then switches to non-root for runtime

Example 2: Node.js Application with Specific User ID

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM node:16-alpine

# Create user with specific UID for consistency
RUN adduser -D -u 1001 nodeuser

# Set working directory
WORKDIR /app

# Copy package files and install dependencies as root
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy application code and set ownership
COPY --chown=nodeuser:nodeuser . .

# Switch to non-root user
USER nodeuser

# Expose application port
EXPOSE 3000

CMD ["node", "server.js"]

Example 3: Multi-Stage Build with Security

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Build stage
FROM node:16-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:16-alpine AS production

# Create non-root user
RUN adduser -D -u 1000 appuser

# Create app directory with proper permissions
RUN mkdir /app && chown appuser:appuser /app

# Copy built application from builder stage
COPY --from=builder --chown=appuser:appuser /build/dist /app/
COPY --from=builder --chown=appuser:appuser /build/package*.json /app/

# Install production dependencies
WORKDIR /app
RUN npm ci --only=production

# Switch to non-root user
USER appuser

EXPOSE 8000
CMD ["node", "index.js"]

Best Practices and Security Considerations

1. Choose Appropriate User IDs

1
2
3
4
# Good: Use UID >= 1000 (standard for regular users)
RUN useradd --uid 1000 appuser

# Avoid: Using UID < 1000 (reserved for system users)

2. Handle File Permissions Correctly

1
2
3
4
5
6
7
# Create directories with proper permissions
RUN mkdir -p /app/data && \
    chown -R appuser:appuser /app && \
    chmod 755 /app

# Set specific permissions for sensitive files
RUN chmod 600 /app/config/secrets.conf

3. Use COPY –chown for Efficiency

1
2
3
4
5
6
# Efficient: Set ownership during copy
COPY --chown=appuser:appuser . /app/

# Less efficient: Copy then change ownership
COPY . /app/
RUN chown -R appuser:appuser /app/

4. Handle Temporary Directories

1
2
3
4
5
6
7
8
# Create and set permissions for temp directories
RUN mkdir -p /app/tmp && \
    chown appuser:appuser /app/tmp && \
    chmod 755 /app/tmp

USER appuser

# Your application can now write to /app/tmp

Common Pitfalls and Solutions

Pitfall 1: Permission Denied Errors

Problem: Your application fails with “Permission denied” errors.

Solution: Ensure the user owns the necessary directories:

1
2
3
# Check what your application needs to write to
RUN mkdir -p /app/logs /app/uploads /app/cache && \
    chown -R appuser:appuser /app

Pitfall 2: Can’t Bind to Privileged Ports

Problem: Application fails to start on port 80 or 443.

Solution: Use non-privileged ports and handle routing externally:

1
2
3
4
# Use port 8080 instead of 80
EXPOSE 8080

# Handle routing with reverse proxy (nginx, traefik, etc.)

Pitfall 3: File Ownership in Volumes

Problem: Mounted volumes have wrong ownership.

Solution: Handle ownership in entrypoint script:

1
2
3
4
5
6
# Create entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

USER appuser
ENTRYPOINT ["/entrypoint.sh"]
1
2
3
4
5
6
7
8
9
#!/bin/bash
# entrypoint.sh

# Fix ownership of mounted volumes if needed
if [ -d "/app/data" ]; then
    sudo chown -R appuser:appuser /app/data 2>/dev/null || true
fi

exec "$@"

Testing Your Secure Containers

Verify User Context

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Build your image
docker build -t secure-app .

# Run and check the user
docker run --rm secure-app whoami
# Should output: appuser (not root)

# Check user ID
docker run --rm secure-app id
# Should show uid=1000(appuser) or similar

Test File Permissions

1
2
3
4
5
6
7
# Try to access restricted files
docker run --rm secure-app ls -la /root/
# Should show "Permission denied"

# Verify application files are accessible
docker run --rm secure-app ls -la /app/
# Should show files owned by appuser

Advanced Security Techniques

Using Security Contexts in Kubernetes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secure-app
spec:
  template:
    spec:
      securityContext:
        runAsUser: 1000
        runAsGroup: 1000
        runAsNonRoot: true
        fsGroup: 1000
      containers:
      - name: app
        image: secure-app:latest
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop:
            - ALL

Read-Only Root Filesystem

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM alpine:3.17

RUN adduser -D appuser && \
    mkdir -p /app /tmp/app && \
    chown -R appuser:appuser /app /tmp/app

USER appuser
WORKDIR /app

# Application will run with read-only root filesystem
# Temporary files go to /tmp/app

Conclusion

Creating container-safe Linux users isn’t just about following best practices – it’s about building a robust defense against security threats. By implementing non-root users, you’re creating multiple layers of protection that significantly reduce your attack surface.

Remember these key principles:

  • Never run production containers as root
  • Create dedicated users for your applications
  • Set appropriate file permissions
  • Use non-privileged ports
  • Test your security configurations

The extra effort you invest in container security today will save you from potential disasters tomorrow. Your applications will be more secure, your infrastructure more resilient, and your peace of mind greatly improved.

Start implementing these practices in your next container build, and make security a cornerstone of your development process, not an afterthought.