Securing Containers with Non-Root Users and Resource Limits

Running applications in production demands not just functionality but also robust security and stable performance. A common oversight in container deployments is operating services with excessive privileges or without proper resource constraints. This can turn a minor vulnerability into a critical system compromise or a simple traffic spike into a cascading outage.

In this chapter, we’ll implement two fundamental production best practices for Docker containers: running services as non-root users and defining explicit CPU and memory limits. These measures significantly reduce your application’s attack surface and ensure predictable resource consumption, making your multi-service stack more resilient.

By the end of this milestone, you will have updated your application’s Dockerfile and docker-compose.yml to incorporate these hardening techniques. We’ll verify these changes by inspecting container user IDs and observing resource usage, confirming that your services are operating under the principle of least privilege and with controlled stability.

Project Overview

Our overarching goal is to build a production-ready, multi-service web application stack using Docker and Docker Compose. Each chapter focuses on a critical aspect of this journey, from initial containerization to advanced deployment and security patterns. This chapter specifically addresses critical security and stability concerns by modifying existing service configurations.

Tech Stack

For this chapter, we will primarily interact with:

  • Docker Engine: The core platform for running containers. (Latest stable release: unknown as of 2026-05-22)
  • Docker Compose: Used to define and run multi-container Docker applications. We adhere to the Compose Specification. (Recommended practice is to omit the version field in docker-compose.yml files for compatibility with the latest Compose Specification, as of 2026-05-22).
  • Dockerfile: Defines the steps to build our application’s container images.
  • Python/Flask Application: (Our example web service) The application code running inside the container.
  • PostgreSQL Database: (Our example database service) A widely used relational database.

Planning & Design for Container Hardening

The core principles guiding our work in this chapter are least privilege for security and resource isolation for stability.

Non-Root User Strategy: Reducing the Attack Surface

By default, processes inside a Docker container run as the root user. This is a significant security risk. If an attacker exploits a vulnerability in a root-run service, they could gain root access within the container, potentially escalating privileges to the Docker host if certain dangerous configurations are present. Running as a non-root user isolates the application, ensuring that even if compromised, the attacker’s capabilities are severely limited.

Our approach involves:

  1. Dedicated User and Group Creation: Within the Dockerfile, we’ll create a new system user and group specifically for our application. System users are ideal for services as they lack login shells and home directories, further reducing exposure.
  2. Explicit File Permissions: We’ll ensure that any directories or files the application needs to write to (e.g., logs, temporary files) are explicitly owned by or writable by this new non-root user. Application code itself typically only needs read access.
  3. User Context Switch: The USER instruction in the Dockerfile will switch the user context for all subsequent commands and the container’s runtime to this non-root user.

Resource Limits Strategy: Ensuring Stability and Predictability

Uncontrolled resource consumption is a common culprit for instability in multi-service environments. A single misbehaving container can consume all available CPU or memory, starving other services or even the host system. This leads to performance degradation, unresponsiveness, or outright crashes. Docker Compose provides mechanisms to define explicit resource limits, enforcing resource isolation.

Our approach involves:

  1. CPU Limits (cpus): We will allocate a specific fraction or multiple of CPU cores to prevent a service from monopolizing processing power. This ensures other services and the host have sufficient CPU cycles.
  2. Memory Limits (memory): We will set a maximum amount of RAM a container can consume. This prevents memory leaks or high-load spikes from causing out-of-memory (OOM) errors that affect the entire system.

These limits are critical for fair resource distribution, preventing cascading failures, and enabling better capacity planning within your multi-service stack.

Milestones and Build Plan

We will tackle the container hardening in a structured manner:

  1. Modify Web Service Dockerfile: Add instructions to create a non-root user (appuser) and switch to it.
  2. Rebuild Web Service Image: Build the new image with the non-root user configuration.
  3. Verify Non-Root User: Start the web service and confirm it’s running as appuser.
  4. Modify Docker Compose Configuration: Add CPU and memory resource limits to both the web and db services in docker-compose.yml.
  5. Restart Services: Bring up the entire stack with the new resource limits applied.
  6. Verify Resource Limits: Use docker stats and docker inspect to confirm limits are active.

Step-by-Step Implementation

We’ll apply these hardening techniques to our example web application (a Python Flask app) and its PostgreSQL database service.

1. Implementing Non-Root Users in the Web Service

First, let’s modify the Dockerfile for our web service. We’ll assume your Dockerfile is located at web/Dockerfile.

Here’s the initial (simplified) Dockerfile for context:

# web/Dockerfile - BEFORE changes
FROM python:3.10-slim-bullseye

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

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

Now, let’s add the necessary instructions to create and switch to a non-root user.

# web/Dockerfile - AFTER changes
FROM python:3.10-slim-bullseye

# 📌 Key Idea: Define user/group IDs as build arguments for flexibility.
# This allows overriding them at build time if specific host UID/GID mappings are needed.
ARG UID=1000
ARG GID=1000

# Create a non-root system user and group.
# --system creates an account without a login shell or home directory, ideal for services.
# --gid and --uid ensure consistent IDs, useful for volume permissions if mapped to host.
RUN groupadd --system --gid ${GID} appgroup && \
    useradd --system --uid ${UID} --gid ${GID} --no-create-home --shell /sbin/nologin appuser

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# 🧠 Important: Ensure necessary directories are owned by the non-root user.
# If files were copied *before* `USER appuser` and `appuser` needs write access to them,
# you must explicitly change ownership. For read-only application code, this is often not needed.
# Example if /app/logs needed to be writable:
# RUN mkdir -p /app/logs && chown -R appuser:appgroup /app/logs

# Switch to the non-root user for all subsequent instructions and the container's runtime.
# This is the critical step for enforcing least privilege.
USER appuser

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

Explanation of changes:

  • ARG UID=1000 GID=1000: We define build arguments for the user and group IDs. Using 1000 is a common default for the first non-root user on many Linux systems, but you can choose others.
  • RUN groupadd ... && useradd ...: This command creates a new system group appgroup and a new system user appuser.
    • --system: Designates them as system accounts, typically for services, without login capabilities.
    • --no-create-home: Prevents creating a home directory for appuser.
    • --shell /sbin/nologin: Ensures the user cannot log in interactively.
  • USER appuser: This is the most crucial instruction. It changes the user context for all subsequent RUN, CMD, and ENTRYPOINT instructions to appuser. This means your application process will run with the privileges of appuser, not root.
  • Permissions Note: The commented chown line is a common necessity. If your application needs to write to any directories that were COPYed into the image before the USER appuser instruction, those directories will be owned by root. You’d need to explicitly change their ownership to appuser:appgroup before switching the user, otherwise, your application might hit “Permission denied” errors. For a typical web application that only reads its code and writes to a specific /var/log or /tmp directory, this might not be needed for /app itself.

2. Rebuilding and Verifying the Web Service

After modifying the Dockerfile, rebuild the web service image:

docker-compose build web

Now, let’s start only the web service and verify the user context.

docker-compose up -d web

To verify the user inside the running container:

# Replace <web_service_container_id> with the actual ID from 'docker ps'
docker exec -it <web_service_container_id> whoami
# Expected output: appuser

docker exec -it <web_service_container_id> id
# Expected output similar to: uid=1000(appuser) gid=1000(appgroup) groups=1000(appgroup)

If you see root or a different user, double-check your Dockerfile for the USER appuser instruction and ensure it’s placed correctly.

3. Implementing Resource Limits in Docker Compose

Next, we’ll add resource limits to our docker-compose.yml file. These are defined under the deploy.resources.limits section for each service. Remember, we are using the latest Compose Specification, so no version field is needed.

Locate your docker-compose.yml file in the project root.

Here’s a simplified version of your docker-compose.yml before changes:

# docker-compose.yml - BEFORE changes (simplified)
services:
  web:
    build: ./web
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://user:password@db:5432/mydatabase
    networks:
      - app_network

  db:
    image: postgres:13
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - app_network

networks:
  app_network:
    driver: bridge

volumes:
  db_data:

Now, let’s add resource limits for both the web and db services.

# docker-compose.yml - AFTER changes
services:
  web:
    build: ./web
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://user:password@db:5432/mydatabase
    networks:
      - app_network
    # 🔥 Optimization / Pro tip: Add resource limits for the web service
    deploy:
      resources:
        limits:
          cpus: '0.5' # Limit to 50% of a CPU core (e.g., half a core)
          memory: 256M # Limit to 256 MB of RAM
        # Optional: Set reservations for guaranteed resources.
        # Docker will try to ensure these resources are available even under contention.
        # reservations:
        #   cpus: '0.25' # Guarantee 25% of a CPU core
        #   memory: 128M # Guarantee 128 MB of RAM

  db:
    image: postgres:13
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - app_network
    # 🔥 Optimization / Pro tip: Add resource limits for the database service
    deploy:
      resources:
        limits:
          cpus: '1.0' # Limit to 1 full CPU core
          memory: 1G # Limit to 1 GB of RAM
        # Optional: Set reservations for guaranteed resources
        # reservations:
        #   cpus: '0.5'
        #   memory: 512M

networks:
  app_network:
    driver: bridge

volumes:
  db_data:

Explanation of changes:

  • deploy: This top-level key under a service allows you to configure deployment-related parameters, including resources.
  • resources.limits.cpus: Specifies the maximum CPU share a container can utilize.
    • '0.5' means the container can use up to 50% of one CPU core.
    • '1.0' means one full CPU core. Values can be fractions or integers.
  • resources.limits.memory: Specifies the maximum amount of RAM the container can consume.
    • 256M means 256 megabytes.
    • 1G means 1 gigabyte.
  • resources.reservations (Optional): While limits are hard caps that prevent a container from exceeding resources, reservations define the guaranteed minimum amount of resources a container will receive. This is particularly useful in environments with resource contention, ensuring your critical services always have a baseline. We’ve included them as comments for awareness.

4. Applying and Verifying Resource Limits

Now, restart your services to apply the new resource limits. The --build flag ensures our updated Dockerfile for the web service is used, and all services are recreated with the new deploy configurations.

docker-compose up -d --build

Verification of Resource Limits

To check if the resource limits are applied and observe real-time usage, use the docker stats command. This provides a live stream of resource usage for your running containers.

docker stats

You should see output similar to this, with your configured limits visible under the MEM USAGE / LIMIT column:

CONTAINER ID   NAME                 CPU %     MEM USAGE / LIMIT     MEM %     NET I/O     BLOCK I/O   PIDS
abcdef123456   yourproject-web-1    0.12%     12.34MiB / 256MiB     4.82%     1.23kB / 0B   0B / 0B     7
fedcba654321   yourproject-db-1     0.34%     80.56MiB / 1GiB       7.87%     2.34kB / 0B   0B / 0B     15

Observe the MEM USAGE / LIMIT column to confirm your configured memory limits (e.g., 256MiB for web, 1GiB for db). The CPU % will fluctuate but will be constrained by the cpus limit if the container attempts to exceed it.

You can also inspect a specific service’s configuration at a lower level:

# Replace <web_service_container_id> with the actual ID from 'docker ps'
docker inspect <web_service_container_id> | grep -E "CpuPeriod|CpuQuota|Memory"

This command will display the low-level Docker Engine configurations that reflect the Compose limits. For example, CpuPeriod and CpuQuota relate to CPU limits, and Memory relates to the memory limit. These values are typically in microseconds and bytes, respectively.

Production Considerations

Enhanced Security Posture

Running containers as non-root users is a cornerstone of modern container security. It adheres to the principle of least privilege, drastically reducing the “blast radius” if a containerized application is compromised. An attacker gaining control of a non-root process has significantly fewer privileges to exploit the host or other containers compared to one running as root.

Stable Resource Management

Resource limits are not merely for preventing runaway processes; they are fundamental for robust capacity planning and maintaining the stability of your entire system. By setting appropriate limits:

  • Prevent Resource Exhaustion: A single misbehaving application or an unexpected surge in load won’t consume all host resources, preventing cascading failures across other critical services.
  • Improve Predictability: Your services will operate within defined boundaries, leading to more consistent performance and easier troubleshooting.
  • Facilitate Scaling: Understanding and quantifying the resource requirements of individual services is essential for making informed decisions when scaling your application horizontally or vertically.

Monitoring and Alerting

While docker stats is excellent for immediate verification, production environments demand dedicated monitoring solutions (e.g., Prometheus with Grafana, Datadog, New Relic). These tools allow you to track container resource usage (CPU, memory, I/O) over time, set up alerts for threshold breaches, identify bottlenecks, and fine-tune your limits proactively.

Fine-tuning Limits and Reservations

The initial resource limits you set are often estimates. It’s crucial to continuously monitor your applications under various load conditions (normal usage, peak traffic, stress tests) to fine-tune these limits.

  • Too Strict Limits: Can lead to performance degradation, application slowdowns, or OOMKilled errors even under normal load.
  • Too Generous Limits: Defeat the purpose of resource isolation and can mask underlying performance issues. Finding the right balance requires iterative testing and observation. Consider using reservations for critical services to guarantee a minimum level of performance.

Common Issues & Solutions

  1. Permission Denied Errors (Non-Root User):

    • Issue: After switching to a non-root user, your application fails to start or crashes during operation with “Permission denied” errors. This often occurs when trying to write to a log file, access configuration, or create temporary files.
    • Cause: The new non-root user lacks write permissions to the necessary directories or files.
    • Solution: Identify the specific directories or files requiring write access. In your Dockerfile, before the USER appuser instruction, add RUN chown -R appuser:appgroup /path/to/writable/directory. For example, if your app writes logs to /app/logs, ensure /app/logs is created and owned by appuser (RUN mkdir -p /app/logs && chown -R appuser:appgroup /app/logs).
    • ⚡ Real-world insight: Be precise with chown. Granting write access to only what’s absolutely necessary further enhances security.
  2. Container Crashes with OOMKilled (Resource Limits):

    • Issue: Your container unexpectedly crashes, and docker logs or docker inspect (under State.OOMKilled) indicates an OOMKilled status.
    • Cause: The memory limit set in docker-compose.yml is too low for your application’s actual memory requirements, especially under load or during specific operations (e.g., data processing).
    • Solution: Increase the memory limit for that service in docker-compose.yml. Use docker stats or detailed monitoring to observe the typical and peak memory usage of your application, then set the limit comfortably above the observed peak to provide a buffer.
  3. Application Slowdowns or Freezing (Resource Limits):

    • Issue: Your application becomes unresponsive or very slow, but doesn’t crash. docker stats might show its CPU % frequently hitting close to its cpus limit.
    • Cause: The cpus limit is too restrictive, and your application genuinely requires more processing power, especially during peak load or computationally intensive tasks.
    • Solution: Increase the cpus limit for that service in docker-compose.yml. Again, continuous monitoring of application performance and CPU usage under realistic load is essential to find the right balance between resource isolation and performance.

Summary & Next Step

You’ve successfully implemented two critical production best practices: running containers as non-root users and setting explicit resource limits. By configuring non-root users, you’ve significantly reduced the attack surface and potential impact of a container compromise, adhering to the principle of least privilege. Simultaneously, by setting resource limits, you’ve established the foundation for a more stable and predictable production environment, preventing any single service from monopolizing system resources.

Your application stack is now more secure and resilient against common operational pitfalls. Next, we will enhance our application’s external accessibility, security, and performance by integrating a reverse proxy with Nginx, further preparing it for real-world deployment.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.