Securing Docker Builds
Table of Contents
Understanding Docker Security
Docker containers have revolutionized application deployment, but they also introduce unique security challenges. While containers provide a level of isolation, they don't offer the same security boundaries as virtual machines. Understanding the security implications of Docker builds is essential for creating robust, production-ready containers.
Docker security encompasses multiple layers, from the base image you choose to the runtime environment where your containers execute. In this tutorial, we'll focus on securing the Docker build process itself, which is your first line of defense against many common vulnerabilities.
Each layer in this security model builds upon the previous ones. By the end of this tutorial, you'll understand how to implement security at each level, starting with the foundation: securing your Docker builds.
Security Is a Process: Remember that Docker security is not a one-time task. It's an ongoing process that requires vigilance, regular updates, and continuous improvement as new vulnerabilities and best practices emerge.
Securing Base Images
Your Docker image's security posture starts with the base image you choose. Base images can contain vulnerabilities that will propagate to your final image, so selecting secure base images is critical.
Use Official and Minimal Images
Official images from Docker Hub (e.g., those maintained by the Docker team or the software vendor) typically follow security best practices and receive regular updates. Within official images, prefer minimal variants:
Base Image | Size | Security Implications |
---|---|---|
node:18 |
~950 MB | Larger attack surface, unnecessary tools |
node:18-slim |
~200 MB | Minimal Debian-based, reduced attack surface |
node:18-alpine |
~170 MB | Very minimal, smallest attack surface |
gcr.io/distroless/nodejs:18 |
~115 MB | Ultra-minimal, no shell, excellent security |
Best Practice: Choose the most minimal base image that meets your requirements. Alpine and distroless images are excellent choices for security-conscious applications.
Pin to Specific Versions
Always pin base images to specific versions or, better yet, to cryptographic digests. This prevents unexpected updates that could introduce vulnerabilities:
Avoid:
FROM node:latest
Using latest
or major version tags can lead to unpredictable builds as these tags point to different images over time.
Better:
FROM node:18.16.0-alpine3.17
Pinning to a specific version provides consistency but still allows for potential rebuilds of the tag.
Best:
FROM node:18.16.0-alpine3.17@sha256:c8573fd25e66bf9c7390d8bd51684fb03a5a6dae07e9dcdbabd28a1f11871af4
Pinning to a specific digest guarantees you get exactly the same base image every time, even if the tag is updated or compromised.
Regular Updates
While pinning versions is important for build stability, you should also regularly update your base images to incorporate security patches. Implement a process to check for security updates to your base images and rebuild your containers accordingly.
Automating Base Image Updates
Tools like Dependabot (for GitHub) can automatically monitor and update your base images when security patches are available:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
pull-request-branch-name:
separator: "-"
commit-message:
prefix: "docker"
include: "scope"
Managing Dependencies Securely
Application dependencies are a major source of security vulnerabilities. Implementing proper dependency management in your Docker builds is essential for security.
Lock Dependencies
Always lock your dependencies to specific versions using lock files (package-lock.json, Pipfile.lock, etc.) and include these in your Docker build:
# Node.js example
COPY package.json package-lock.json ./
RUN npm ci # Uses package-lock.json for exact versions
# Python example
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
Use npm ci
instead of npm install
for Node.js projects as it strictly adheres to the lock file and is more suitable for production environments.
Scan Dependencies
Integrate dependency scanning into your build process to identify known vulnerabilities:
# Example incorporating dependency scanning in a Node.js build
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Scan dependencies for vulnerabilities
FROM deps AS security-check
RUN npm audit --audit-level=high
# Or use a dedicated security scanner:
# RUN npx audit-ci --high
# Continue with the build if security check passes
FROM deps AS builder
COPY . .
RUN npm run build
This approach will fail the build if high-severity vulnerabilities are found, preventing the deployment of vulnerable images.
Use Production Dependencies Only
In multi-stage builds, include only production dependencies in your final image to reduce attack surface:
# Build stage with all dependencies
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage with only production dependencies
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist /app/dist
CMD ["node", "dist/server.js"]
Implementing Least Privilege
The principle of least privilege is fundamental to container security. By default, Docker containers run as root, which presents significant security risks if the container is compromised.
Use Non-root Users
Create and use a non-root user in your Dockerfile:
# Create a non-root user
RUN addgroup -g 1001 -S appuser && \
adduser -u 1001 -S appuser -G appuser
# Set ownership of application files
COPY --chown=appuser:appuser . .
# Switch to non-root user
USER appuser
CMD ["npm", "start"]
Many official images already include non-root users that you can utilize:
# Node.js official images include a 'node' user
FROM node:18-alpine
WORKDIR /app
COPY --chown=node:node . .
# Switch to non-root user
USER node
CMD ["node", "server.js"]
Important: Changing to a non-root user only affects runtime commands (CMD, ENTRYPOINT) and any RUN instructions that come after the USER directive. Earlier RUN instructions still execute as root.
Set Proper File Permissions
Ensure proper file permissions for application files:
# Set permissions for sensitive files
COPY --chown=appuser:appuser config/secrets.json /app/config/
RUN chmod 600 /app/config/secrets.json
Use Read-Only File Systems
At runtime, consider mounting your container's file system as read-only and providing explicit writable volumes only where needed:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /var/tmp \
--volume /path/to/logs:/app/logs \
your-secure-image
Handling Secrets and Sensitive Data
Secrets management is a critical aspect of Docker security. Embedding secrets directly in your Docker images is a common but dangerous practice.
Never Hardcode Secrets
Never do this:
ENV AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
ENV AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Secrets in ENV instructions are visible in the image history and accessible to anyone who can pull the image.
Use Build Arguments Carefully
While build arguments (ARG) don't appear in the final image, they are still visible in the build history:
ARG API_KEY
RUN echo "API_KEY=$API_KEY" >> .env
This approach is still insecure as the ARG values are recorded in the image's build cache.
Use Runtime Secrets
The most secure approach is to inject secrets at runtime, not build time:
Docker Secrets (Swarm)
# Create a secret
echo "my_secret_data" | docker secret create api_key -
# Use the secret in a service
docker service create \
--name my-app \
--secret api_key \
my-secure-image
Inside the container, the secret is available at /run/secrets/api_key
.
Kubernetes Secrets
apiVersion: v1
kind: Secret
metadata:
name: api-key
type: Opaque
data:
api_key: bXlfc2VjcmV0X2RhdGE= # Base64 encoded
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: app
image: my-secure-image
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: api-key
key: api_key
Multi-stage Builds for Secrets
If you absolutely need secrets during build time, you can use multi-stage builds to ensure they don't appear in the final image:
# Build stage that uses a secret
FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci && \
npm run build && \
rm -f .npmrc
# Final stage without the secret
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist /app/dist
CMD ["node", "dist/index.js"]
Note: This approach still exposes secrets in the build cache. For truly sensitive data, use a more secure approach like BuildKit's secret mounting or a dedicated secrets manager.
BuildKit Secret Mounting
Docker BuildKit provides a secure way to use secrets during build without caching them:
# syntax=docker/dockerfile:1.4
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc npm ci
CMD ["npm", "start"]
Build with:
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=.npmrc -t my-secure-image .
Scanning Images for Vulnerabilities
Vulnerability scanning is an essential step in securing your Docker images. By identifying vulnerabilities before deployment, you can prevent security issues from reaching production.
Common Vulnerability Scanners
Scanner | Features | Integration |
---|---|---|
Docker Scout | OS and application dependencies, recommended for Docker Desktop users | Docker CLI, CI/CD |
Trivy | Open-source, comprehensive, detects vulnerabilities in OS packages and language dependencies | CLI, CI/CD, Kubernetes |
Clair | Open-source, static analysis of vulnerabilities in container images | CI/CD, container registries |
Snyk | Commercial, comprehensive security for containers, applications, and infrastructure as code | CLI, CI/CD, IDE plugins |
Integrating Scanning in CI/CD
Integrate image scanning into your CI/CD pipeline to automatically check for vulnerabilities before deployment:
GitHub Actions Example with Trivy
name: Docker Build and Scan
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build the Docker image
run: docker build -t my-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'my-app:${{ github.sha }}'
format: 'table'
exit-code: '1'
ignore-unfixed: true
severity: 'CRITICAL,HIGH'
Configure vulnerability scanners to fail builds on critical or high-severity vulnerabilities, especially those with known exploits or affecting components exposed to untrusted users.
Fixing Vulnerabilities
When vulnerabilities are detected, there are several remediation strategies:
- Update dependencies to versions with security fixes
- Switch to a more recent or alternative base image that doesn't contain the vulnerability
- Apply security patches if available
- Implement mitigating controls if the vulnerability cannot be immediately fixed
Updating a Vulnerable Dependency
# Example: Updating a vulnerable Node.js dependency
npm update lodash --depth 3 # Update lodash up to 3 levels deep
npm audit fix # Automatically fix vulnerabilities where possible
Runtime Security Considerations
While this tutorial focuses on securing the Docker build process, it's important to understand that build-time security is just one aspect of container security. Runtime security is equally important and involves several additional considerations.
Limit Container Capabilities
By default, Docker containers run with a reduced set of Linux capabilities, but you can restrict them further:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE my-secure-image
This command drops all capabilities and then adds back only the specific capability needed to bind to privileged ports.
Resource Limits
Set resource limits to prevent denial-of-service attacks:
docker run --memory=512m --cpu-shares=512 my-secure-image
Read-only Root Filesystem
Run containers with a read-only root filesystem:
docker run --read-only --tmpfs /tmp my-secure-image
Security Profiles
Apply security profiles like Seccomp, AppArmor, or SELinux:
docker run --security-opt seccomp=/path/to/profile.json my-secure-image
These runtime security options are typically configured in orchestration platforms like Kubernetes or Docker Compose rather than in individual docker run commands.
Security in CI/CD Pipelines
Securing your Docker builds is most effective when integrated into your CI/CD pipeline. This ensures that security checks are consistently applied to every build.
Essential Security Steps in CI/CD
- Static Analysis of Dockerfiles to check for security best practices
- Dependency Scanning to identify vulnerabilities in application dependencies
- Image Scanning to identify vulnerabilities in the built image
- Image Signing to verify image authenticity
- Policy Enforcement to prevent deployment of insecure images
Complete CI/CD Pipeline Example
name: Secure Docker Build Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Lint Dockerfile
uses: hadolint/[email protected]
with:
dockerfile: Dockerfile
- name: Build the Docker image
run: docker build -t my-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'my-app:${{ github.sha }}'
format: 'table'
exit-code: '1'
ignore-unfixed: true
severity: 'CRITICAL,HIGH'
- name: Sign the image (with Cosign)
if: github.ref == 'refs/heads/main'
run: |
cosign sign --key ${{ secrets.COSIGN_KEY }} my-app:${{ github.sha }}
- name: Push to registry
if: github.ref == 'refs/heads/main'
run: |
docker tag my-app:${{ github.sha }} registry.example.com/my-app:latest
docker push registry.example.com/my-app:latest
Docker Security Checklist
Use this checklist to ensure you've covered the key aspects of Docker build security:
Base Image Security
Dependency Management
Least Privilege
Secrets Management
Image Scanning and CI/CD
Conclusion
Securing Docker builds is a critical first step in container security. By implementing the practices outlined in this tutorial, you can significantly reduce the attack surface of your containerized applications and protect against common vulnerabilities.
Remember that security is a continuous process. Stay informed about new security best practices, vulnerabilities, and tools. Regularly update your base images, dependencies, and security configurations to maintain a strong security posture.
Key takeaways from this tutorial:
- Use minimal, official base images and pin them to specific digests
- Manage dependencies securely with lock files and regular updates
- Implement the principle of least privilege with non-root users
- Handle secrets securely using runtime injection or secret mounts
- Scan images for vulnerabilities before deployment
- Integrate security checks into your CI/CD pipeline
By applying these principles, you'll build more secure Docker images and help protect your applications and infrastructure from security threats.
Try Our Docker Security Tools
Ready to scan your Dockerfiles for security vulnerabilities? Try our free Docker Security Scanner to identify and fix security issues in your Docker builds.
Try Docker Security Scanner