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
versionfield indocker-compose.ymlfiles 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:
- 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. - 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.
- User Context Switch: The
USERinstruction in theDockerfilewill 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:
- 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. - 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:
- Modify Web Service Dockerfile: Add instructions to create a non-root user (
appuser) and switch to it. - Rebuild Web Service Image: Build the new image with the non-root user configuration.
- Verify Non-Root User: Start the web service and confirm it’s running as
appuser. - Modify Docker Compose Configuration: Add CPU and memory resource limits to both the
webanddbservices indocker-compose.yml. - Restart Services: Bring up the entire stack with the new resource limits applied.
- Verify Resource Limits: Use
docker statsanddocker inspectto 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. Using1000is 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 groupappgroupand a new system userappuser.--system: Designates them as system accounts, typically for services, without login capabilities.--no-create-home: Prevents creating a home directory forappuser.--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 subsequentRUN,CMD, andENTRYPOINTinstructions toappuser. This means your application process will run with the privileges ofappuser, notroot.- Permissions Note: The commented
chownline is a common necessity. If your application needs to write to any directories that wereCOPYed into the image before theUSER appuserinstruction, those directories will be owned byroot. You’d need to explicitly change their ownership toappuser:appgroupbefore 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/logor/tmpdirectory, this might not be needed for/appitself.
2. Rebuilding and Verifying the Web Service
After modifying the Dockerfile, rebuild the web service image:
docker-compose build webNow, let’s start only the web service and verify the user context.
docker-compose up -d webTo 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.256Mmeans 256 megabytes.1Gmeans 1 gigabyte.
resources.reservations(Optional): Whilelimitsare hard caps that prevent a container from exceeding resources,reservationsdefine 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 --buildVerification 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 statsYou 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 15Observe 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
OOMKillederrors 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
reservationsfor critical services to guarantee a minimum level of performance.
Common Issues & Solutions
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 theUSER appuserinstruction, addRUN chown -R appuser:appgroup /path/to/writable/directory. For example, if your app writes logs to/app/logs, ensure/app/logsis created and owned byappuser(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.
Container Crashes with
OOMKilled(Resource Limits):- Issue: Your container unexpectedly crashes, and
docker logsordocker inspect(underState.OOMKilled) indicates anOOMKilledstatus. - Cause: The memory limit set in
docker-compose.ymlis too low for your application’s actual memory requirements, especially under load or during specific operations (e.g., data processing). - Solution: Increase the
memorylimit for that service indocker-compose.yml. Usedocker statsor 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.
- Issue: Your container unexpectedly crashes, and
Application Slowdowns or Freezing (Resource Limits):
- Issue: Your application becomes unresponsive or very slow, but doesn’t crash.
docker statsmight show itsCPU %frequently hitting close to itscpuslimit. - Cause: The
cpuslimit is too restrictive, and your application genuinely requires more processing power, especially during peak load or computationally intensive tasks. - Solution: Increase the
cpuslimit for that service indocker-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.
- Issue: Your application becomes unresponsive or very slow, but doesn’t crash.
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
- Docker Documentation: https://docs.docker.com/
- Docker Compose file reference: https://docs.docker.com/compose/compose-file/
- Compose Specification (recommended to avoid
versionfield): https://docs.docker.com/compose/compose-file/07-general-params/#version - Best practices for writing Dockerfiles: https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
- Docker
useraddcommand: https://docs.docker.com/engine/reference/builder/#useradd
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.