Security Best Practices for Docker Images

Container security has become a critical concern as more organizations adopt Docker for their production deployments. While containers provide excellent isolation and reproducibility, they also introduce unique security challenges that must be addressed during the build process.

In this article, we'll explore essential security best practices for building and maintaining Docker images to ensure your containerized applications remain secure throughout their lifecycle.

Why Docker Image Security Matters

The security of your Docker images is foundational to your overall container security posture. An insecure base image or build process can introduce vulnerabilities that persist throughout your container ecosystem, potentially leading to:

  • Data breaches from exploited vulnerabilities
  • Privilege escalation and container escapes
  • Unauthorized access to host systems
  • Lateral movement between containers
  • Resource exhaustion and denial-of-service

According to a 2024 container security report, over 60% of production Docker images contain at least one high-severity vulnerability, with many of these being easily preventable through proper build practices.

Let's dive into the key security practices that can help you build more secure Docker images:

1. Use Minimal Base Images

Critical

Start with the smallest possible base image to reduce your attack surface. The larger your image, the more potential vulnerabilities it contains. Consider the following options, from most to least minimal:

  • Scratch: The empty image, suitable for compiled languages like Go
  • Distroless: Contains only your application and its runtime dependencies
  • Alpine: A lightweight Linux distribution (~5MB)
  • Slim variants: Trimmed-down versions of standard images

Before Optimization:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]

After Optimization:

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

The optimized version uses the Alpine-based Node.js image, which is substantially smaller than the default Debian-based image, and installs only production dependencies.

Security Impact:
Critical - Reduces attack surface by 60-90%

2. Never Run as Root

Critical

Containers that run as root significantly increase risk. If an attacker compromises a container running as root, they can potentially gain root access to the host through container escape vulnerabilities.

Always create a dedicated non-root user in your Docker image:

FROM node:18-alpine

# Create app directory
WORKDIR /app

# Copy application code
COPY . .

# Install dependencies
RUN npm ci --only=production

# Create a non-root user with a known uid/gid
RUN addgroup -S appgroup && adduser -S appuser -G appgroup -u 1001

# Give ownership to the non-root user
RUN chown -R appuser:appgroup /app

# Switch to non-root user
USER appuser

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

Additionally, configure your Kubernetes deployment or Docker Compose file to run with security contexts that enforce non-root execution:

# In Kubernetes
securityContext:
  runAsUser: 1001
  runAsGroup: 1001
  runAsNonRoot: true
Security Impact:
Critical - Prevents privilege escalation

3. Scan for Vulnerabilities

Essential

Regularly scan your Docker images for known vulnerabilities. This process should be integrated into your CI/CD pipeline to fail builds that introduce critical vulnerabilities.

Several tools exist for this purpose:

  • Trivy: A simple and comprehensive scanner
  • Clair: Open-source vulnerability scanner by CoreOS
  • Synk: Commercial scanner with free tier
  • Docker Scout: Docker's integrated scanning solution
  • Anchore: Deep analysis scanning

Here's how to integrate Trivy scanning in your CI pipeline:

# In your CI pipeline
docker build -t myapp:latest .

# Scan the image (fail on HIGH and CRITICAL vulnerabilities)
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest

# If scan passes, push to registry
docker push myapp:latest

Consider implementing a policy that prevents deployment of images with high-severity vulnerabilities for which patches are available.

Security Impact:
High - Identifies and mitigates known vulnerabilities

4. Implement Multi-stage Builds

Essential

Multi-stage builds allow you to use one container for building your application (which includes build tools, development dependencies, etc.) and a separate, minimal container for running it. This significantly reduces your final image size and attack surface.

# Build stage
FROM node:18 AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Production stage
FROM node:18-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm install --only=production

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup -u 1001
RUN chown -R appuser:appgroup /app
USER appuser

EXPOSE 3000
CMD ["node", "dist/server.js"]

Benefits include:

  • Smaller final images with fewer vulnerabilities
  • Development dependencies and build tools excluded from production
  • Build secrets (like access tokens) don't persist in the final image layers
  • Improved organization of the build process
Security Impact:
High - Reduces attack surface and prevents leakage of build secrets

5. Never Hard-code Secrets

Critical

Docker images often end up in public or shared repositories. Even private repositories may be accessible to multiple team members. Never include sensitive data like credentials, API keys, or certificates directly in your Dockerfile or application code.

Bad Practice:

FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
# Hard-coded credentials - NEVER do this!
ENV API_KEY="supersecretkey12345"
ENV DB_PASSWORD="password123"
CMD ["npm", "start"]

Better Approaches:

1. Use environment variables at runtime:

docker run -e API_KEY=supersecretkey12345 -e DB_PASSWORD=password123 myapp:latest

2. Use Docker secrets (in Swarm mode):

echo "supersecretkey12345" | docker secret create api_key -
docker service create --name myapp --secret api_key myapp:latest

3. Use Kubernetes secrets:

apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  api-key: c3VwZXJzZWNyZXRrZXkxMjM0NQ==  # Base64 encoded
  db-password: cGFzc3dvcmQxMjM=  # Base64 encoded

For additional security in CI/CD pipelines, consider using BuildKit's secret mounting to provide secrets during build time without them persisting in layers:

# syntax=docker/dockerfile:1.4
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN --mount=type=secret,id=npm_token npm install
CMD ["npm", "start"]
Security Impact:
Critical - Prevents exposure of sensitive credentials

6. Minimize Unnecessary Packages

Important

Every package installed in your Docker image increases the potential attack surface. Only install packages that are absolutely necessary for your application to run in production.

  • Avoid installing debugging tools or utilities that aren't needed for runtime
  • Clean package manager caches after installation
  • Use the --no-install-recommends flag with apt-get (Debian/Ubuntu)
  • Remove unnecessary build dependencies after compilation
FROM ubuntu:22.04
WORKDIR /app

# Install dependencies in a single layer, then clean up
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Copy application
COPY . .

# Create non-root user
RUN useradd -r -u 1001 -g appgroup appuser
USER appuser

CMD ["./app"]
Security Impact:
Medium-High - Reduces potential vulnerabilities from unnecessary software

7. Use Docker Content Trust

Advanced

Docker Content Trust (DCT) ensures that you're only using and deploying images that were signed by trusted publishers. This prevents tampering and man-in-the-middle attacks during image distribution.

To enable Docker Content Trust:

# Enable globally (or for a session)
export DOCKER_CONTENT_TRUST=1

# Sign an image when pushing
docker push myregistry/myapp:latest

# Pull only signed images 
docker pull myregistry/myapp:latest

When DCT is enabled, Docker will only pull or run signed images. Unsigned images will be rejected, providing protection against unauthorized or tampered images.

Security Impact:
Medium - Ensures image integrity and provenance

Security Checklist for Docker Images

Use this checklist to evaluate the security of your Docker images:

Security Measure Description Priority
Minimal base image Use Alpine, distroless, or slim variants Critical
Non-root user Create and use a dedicated non-root user Critical
Vulnerability scanning Regularly scan for and patch known CVEs Critical
Multi-stage builds Separate build and runtime environments High
No hard-coded secrets Use runtime secrets or mount them securely Critical
Minimal packages Only install what's needed for production High
Content Trust Sign and verify images Medium
Specific version tags Avoid "latest" tag in production Medium
Read-only filesystem Mount containers as read-only where possible Medium
Health checks Implement HEALTHCHECK instruction Medium
Drop capabilities Use --cap-drop to limit container privileges High
Use .dockerignore Prevent sensitive files from being included High

Implementing Security in the CI/CD Pipeline

To ensure consistent application of security best practices, integrate security checks into your CI/CD pipeline:

# Example GitHub Actions workflow
name: Docker Image CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    
    - name: Build Docker image
      run: docker build -t myapp:${{ github.sha }} .
    
    - name: Run Dockerfile linter
      uses: brpaz/[email protected]
    
    - name: Scan for vulnerabilities
      uses: aquasecurity/trivy-action@master
      with:
        image-ref: myapp:${{ github.sha }}
        format: 'table'
        exit-code: '1'
        severity: 'CRITICAL,HIGH'
    
    - name: Check for secrets in image
      run: |
        docker save myapp:${{ github.sha }} | tar -xO | grep -i "key\|secret\|password\|token" && exit 1 || exit 0
    
    - name: Check image runs as non-root
      run: |
        USER=$(docker inspect --format '{{.Config.User}}' myapp:${{ github.sha }})
        if [ "$USER" = "" ] || [ "$USER" = "0" ] || [ "$USER" = "root" ]; then
          echo "Image runs as root! This is a security risk."
          exit 1
        fi

Conclusion

Securing Docker images is a critical component of container security. By implementing the practices outlined in this article, you can significantly reduce the attack surface of your containerized applications and prevent common security issues.

Remember that security is an ongoing process, not a one-time task. Regularly update your base images, scan for new vulnerabilities, and stay informed about container security best practices as they evolve.

To learn more about Docker security, check out our Securing Docker Builds tutorial and Docker Security Scanner tool.

Share this article

Related Articles

Comparing Docker Build Strategies

May 16, 2025

Explore different approaches to building Docker images and find the best strategy for your workflow.

Case Study: Optimizing Build Pipelines for Microservices

Apr 28, 2025

Learn how a financial company reduced their build times by 70% and improved security with advanced Docker techniques.

Securing Docker Builds Tutorial

Mar 15, 2025

A step-by-step guide to implementing secure Docker build practices in your workflow.

Comments

Comments system placeholder. In a real implementation, this would be integrated with a third-party comments system or custom solution.