Docker Container Security: Best Practices for Developers

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Try It Right Now in 5 Minutes

Before diving into theory, let’s see the difference firsthand. Run this command to check what privileges your container is running with:

# Check which user is running inside the container
docker exec -it your_container whoami

# If it returns 'root' → your container has a security problem

If the result is root, this article is for you. Running as root inside a container is like leaving your front door unlocked — usually fine, but if there’s a vulnerability in your app, an attacker can easily escalate to the host.

The quickest fix: add a USER directive to your Dockerfile:

FROM node:20-alpine

# Create a dedicated user instead of using root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Switch to appuser before starting the app
USER appuser

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

Rebuild, restart, and whoami will now return appuser. Done. You just closed one of the most common security gaps that many teams are still ignoring.

Why Docker’s Isolation Isn’t Enough on Its Own

Docker uses Linux namespaces and cgroups to isolate containers — that sounds solid, but this is software isolation, not hardware. Containers still share the kernel with the host. CVE-2019-5736 (a runc vulnerability) and CVE-2020-15257 (Containerd) are textbook examples: exploiting a runtime vulnerability can allow an attacker to escape the container and take over the host — known as a container escape.

I ran into this exact scenario when reviewing an old project: the container was running as root, had /var/run/docker.sock mounted inside, and the app had an RCE vulnerability. Those three things together were a disaster — an attacker could control the entire Docker daemon on the host without needing any further exploitation.

The Principle of Least Privilege

Each container should have exactly the permissions it needs. Nothing more. A basic checklist:

  • Non-root user: Always run containers as a non-root user
  • Read-only filesystem: Mount the filesystem read-only if the app doesn’t need to write
  • Drop capabilities: Remove unnecessary Linux capabilities
  • No privileged mode: Never use --privileged unless absolutely necessary

Read-only Filesystem

In practice, about 80% of Node.js/Python web apps don’t need to write to disk at runtime — just add a tmpfs for /tmp and you’re covered. If an attacker has RCE but can’t write files, establishing persistence becomes much harder:

# Run a container with a read-only filesystem
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  -p 3000:3000 \
  your-app:latest

# Or in docker-compose.yml
services:
  app:
    image: your-app:latest
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

--tmpfs creates a temporary in-memory area for directories the app needs to write to (temp files, PID files).

Advanced: Network Isolation and Secret Management

Network Isolation with Custom Bridge Networks

By default, all containers on the same Docker host can see each other through the bridge network. Nginx can reach the database, the app can reach Redis, monitoring can see everything — there are no boundaries. With 5–10 services, this is a significant attack surface.

When I migrated from docker-compose v1 to v2 for my entire stack, I took the opportunity to define clearer network boundaries — the process was quite smooth and this is the pattern I’m using now:

version: '3.8'

services:
  nginx:
    image: nginx:alpine
    networks:
      - frontend
    ports:
      - "80:80"

  app:
    image: your-app:latest
    networks:
      - frontend   # Receives requests from nginx
      - backend    # Communicates with the database

  db:
    image: postgres:15
    networks:
      - backend    # Only the app can access this, nginx cannot
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

networks:
  frontend:
  backend:
    internal: true  # No internet access from the backend network

secrets:
  db_password:
    file: ./secrets/db_password.txt

With this config, nginx cannot connect directly to the database — all traffic must go through the app layer. If nginx gets compromised, the attacker can’t jump straight into the DB.

Secret Management — Don’t Store Secrets in Environment Variables

Env vars are convenient but have a serious problem: any child process can inherit and read them. On top of that, docker inspect your_container will dump all env vars — anyone with permission to run that command on the host can read your DB password. Crash dumps and application logs are equally common sources of leaks.

Docker Secrets (for Swarm) or file mounts are a better approach:

# Create a secret from a file
echo "super_secret_password" | docker secret create db_password -

# The secret is mounted at /run/secrets/db_password inside the container
# The app reads from the file instead of an env var
# In a Python app: read the secret from a file
import os

def get_db_password():
    secret_path = '/run/secrets/db_password'
    if os.path.exists(secret_path):
        with open(secret_path, 'r') as f:
            return f.read().strip()
    # Fallback for development environment
    return os.environ.get('DB_PASSWORD', '')

Drop Linux Capabilities

Containers have a set of Linux capabilities by default that a typical web app simply doesn’t need. Drop them all and only add back what’s required:

services:
  app:
    image: your-app:latest
    cap_drop:
      - ALL          # Drop all capabilities
    cap_add:
      - NET_BIND_SERVICE  # Only add back what's needed (e.g., binding to ports < 1024)
    security_opt:
      - no-new-privileges:true  # Prevent privilege escalation by child processes

Practical Tips for Real-World Projects

Scan Images Before Deploying

Base images always have vulnerabilities — the only question is how many are CRITICAL. Use Trivy to find out before pushing to production:

# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

# Scan an image
trivy image your-app:latest

# Only show HIGH and CRITICAL severity
trivy image --severity HIGH,CRITICAL your-app:latest

# Use in CI/CD, fail the pipeline if CRITICAL vulnerabilities are found
trivy image --exit-code 1 --severity CRITICAL your-app:latest

Use Distroless or Alpine to Reduce Attack Surface

The smaller the image, the fewer tools available, and the less an attacker can do once inside a container. Node.js 20 Alpine is around 180MB; distroless is even smaller and ships without a shell — no bash, no curl, nothing to pivot with:

# Multi-stage build: build on a full image, run on distroless
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Final stage: no shell, no package manager
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
USER nonroot
CMD ["server.js"]

Never Mount the Docker Socket into a Container

This mistake shows up frequently in older tutorials — mounting /var/run/docker.sock so a container can call the Docker API. It seems convenient, but don’t do it in production. Anyone with access to the socket controls the entire Docker daemon, which is equivalent to root on the host.

Alternatives: use Portainer Agent (covered separately on this blog) or the Docker API with TLS authentication.

Set Resource Limits

Not directly a security measure, but it limits the blast radius of a DoS attack:

services:
  app:
    image: your-app:latest
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          memory: 256M

Quick Pre-Deploy Checklist

  • [ ] Container runs as a non-root user (USER in Dockerfile)
  • [ ] No secrets in env vars or image layers
  • [ ] --read-only if the app doesn’t need to write to disk
  • [ ] no-new-privileges:true in security_opt
  • [ ] Network isolation: database is not exposed externally
  • [ ] Image scanned with Trivy, no CRITICAL vulnerabilities
  • [ ] Base image uses alpine or distroless
  • [ ] /var/run/docker.sock is not mounted
  • [ ] Resource limits are configured

Container security isn’t a one-time setup. I re-scan images every two weeks and require a scan before every release — in practice, the majority of CRITICAL vulnerabilities come from base images, not my own code. Every time you update a dependency or add a new service, run through the checklist again. Base image got a security update? Rebuild immediately — don’t let it slide to the next sprint.

Share: