Docker containerization provides isolation but requires careful security configuration. Docker hardening focuses on daemon exposure, image provenance, runtime privilege boundaries, and kernel security features. This guide covers securing the Docker daemon, images, containers, and runtime environment.
Never expose the Docker socket to untrusted users or containers. Access to /var/run/docker.sock is equivalent to root access.
Secure socket permissions:
# Check current permissions
ls -la /var/run/docker.sock
# Should be owned by root:docker with 660 permissions
# crw-rw---- 1 root docker ... /var/run/docker.sock
# Restrict socket permissions
sudo chmod 660 /var/run/docker.sock
sudo chown root:docker /var/run/docker.sock
Restrict Docker group membership:
# List Docker group members
getent group docker
# Remove unauthorized users
sudo gpasswd -d username docker
# Verify membership
groups username
Never mount Docker socket in containers:
# BAD - Don't do this in production
# docker-compose.yml
services:
app:
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ⚠️ Security risk!
# GOOD - Remove socket mount
services:
app:
volumes:
- app-data:/data
Rootless mode runs Docker daemon and containers as non-root user, reducing privilege escalation risks.
Install rootless Docker:
# Install Docker (if not already installed)
curl -fsSL https://get.docker.com | sh
# Enable rootless mode
dockerd-rootless-setuptool.sh install
# Verify rootless installation
docker info | grep -i rootless
# Should show: Rootless: true
Configure rootless Docker:
# Add to ~/.bashrc or ~/.zshrc
export PATH=/home/username/bin:$PATH
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
# Start rootless Docker
systemctl --user start docker
Rootless mode limitations:
--privileged containers--oom-kill-disableIf remote Docker API access is required, always use TLS with client certificate authentication.
Generate TLS certificates:
# Create CA
mkdir -p ~/.docker/tls
cd ~/.docker/tls
# Generate CA private key and certificate
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem
# Generate server key and certificate
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=server.example.com" -new -key server-key.pem -out server.csr
echo "subjectAltName = DNS:server.example.com,IP:192.168.1.100,IP:127.0.0.1" >> extfile.cnf
echo "extendedKeyUsage = serverAuth" >> extfile.cnf
openssl x509 -req -days 365 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -extfile extfile.cnf
# Generate client key and certificate
openssl genrsa -out key.pem 4096
openssl req -subj '/CN=client.example.com' -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" >> extfile.cnf
openssl x509 -req -days 365 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out cert.pem -extfile extfile.cnf
# Set permissions
chmod -v 0400 ca-key.pem key.pem server-key.pem
chmod -v 0444 ca.pem server-cert.pem cert.pem
Configure Docker daemon for TLS:
# Edit /etc/docker/daemon.json
{
"tls": true,
"tlsverify": true,
"tlscacert": "/home/docker/.docker/tls/ca.pem",
"tlscert": "/home/docker/.docker/tls/server-cert.pem",
"tlskey": "/home/docker/.docker/tls/server-key.pem",
"hosts": ["tcp://0.0.0.0:2376"]
}
# Restart Docker
sudo systemctl restart docker
Connect with TLS:
# Set environment variables
export DOCKER_HOST=tcp://server.example.com:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH=/home/username/.docker/tls
# Verify connection
docker ps
Edit /etc/docker/daemon.json:
{
"userns-remap": "default",
"live-restore": true,
"no-new-privileges": true,
"userland-proxy": false,
"iptables": true,
"ip-forward": true,
"ip-masq": true,
"icc": false,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2",
"storage-opts": [
"overlay2.override_kernel_check=true"
]
}
Restart Docker:
sudo systemctl restart docker
Pull images from trusted sources:
# GOOD - Official images
docker pull nginx:alpine
docker pull postgres:15
# GOOD - Verified publishers
docker pull docker/whalesay
# BAD - Unknown sources
docker pull randomuser/potentially-malicious-image
Use Docker Content Trust (DCT):
# Enable DCT
export DOCKER_CONTENT_TRUST=1
# Add to ~/.bashrc for persistence
echo "export DOCKER_CONTENT_TRUST=1" >> ~/.bashrc
# Push signed image
docker push trusted-registry.example.com/myapp:1.0
# Pull will only work for signed images
docker pull trusted-registry.example.com/myapp:1.0
Never use latest tag in production. Always pin to specific versions or digests.
Dockerfile best practices:
# BAD - Don't use latest
FROM node:latest
FROM ubuntu:latest
# GOOD - Pin to specific version
FROM node:20.11.0-alpine3.19
FROM ubuntu:22.04
# BEST - Pin to digest
FROM node@sha256:abc123...
FROM ubuntu@sha256:def456...
Docker Compose best practices:
# BAD
services:
app:
image: myapp:latest
# GOOD
services:
app:
image: myapp:1.2.3
# BEST
services:
app:
image: myapp@sha256:abc123...
Use Docker Scout (Docker Desktop):
# Scan image
docker scout cves myapp:1.0
# Generate report
docker scout cves myapp:1.0 --format json > scan-results.json
# Check for critical vulnerabilities
docker scout cves myapp:1.0 --severity CRITICAL
Use Trivy (open source):
# Install Trivy
sudo apt install trivy # Debian/Ubuntu
sudo dnf install trivy # RHEL/Fedora
# Scan image
trivy image myapp:1.0
# Scan with severity filter
trivy image --severity CRITICAL,HIGH myapp:1.0
# Generate report
trivy image -f json -o results.json myapp:1.0
Use Grype:
# Install Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh
# Scan image
grype myapp:1.0
# Generate SARIF report
grype -o sarif myapp:1.0 > results.sarif
Use minimal base images:
# BAD - Large attack surface
FROM ubuntu:22.04
# GOOD - Smaller
FROM debian:bookworm-slim
# BETTER - Minimal
FROM alpine:3.19
# BEST - Distroless (no shell, package manager)
FROM gcr.io/distroless/static-debian11
Multi-stage builds to reduce image size:
# Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .
# Runtime stage (minimal)
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
Remove unnecessary packages:
FROM alpine:3.19
# Install only what's needed
RUN apk add --no-cache ca-certificates tzdata
# Remove unnecessary users
RUN deluser --remove-home nobody
# Clean up
RUN rm -rf /var/cache/apk/* /tmp/*
Remove unused images, containers, and volumes:
# Remove dangling images
docker image prune
# Remove all unused images
docker image prune -a
# Remove stopped containers
docker container prune
# Remove unused volumes
docker volume prune
# Remove unused networks
docker network prune
# Remove everything (use with caution!)
docker system prune -a --volumes
Automate cleanup:
# Add to crontab
0 2 * * * /usr/bin/docker system prune -f --volumes >> /var/log/docker-prune.log 2>&1
Never use --privileged unless absolutely necessary. Privileged containers have full access to host devices and kernel capabilities.
Check for privileged containers:
# List privileged containers
docker ps --filter "status=running" --format '{{.ID}} {{.Names}}' | while read id; do
docker inspect --format '{{.HostConfig.Privileged}}' $id | grep -q true && echo "Privileged: $id"
done
# Or simpler
docker inspect --format '{{.Name}}: {{.HostConfig.Privileged}}' $(docker ps -q)
Use specific capabilities instead:
# BAD - Full privileges
docker run --privileged nginx
# GOOD - Specific capabilities
docker run --cap-add=NET_ADMIN --cap-add=SYS_TIME nginx
# BETTER - Drop all, add only needed
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
Drop unnecessary capabilities and add only what’s needed:
Available capabilities:
| Capability | Description | Risk Level |
|---|---|---|
| CAP_CHOWN | Change file ownership | Medium |
| CAP_DAC_OVERRIDE | Bypass file permissions | High |
| CAP_NET_ADMIN | Network administration | High |
| CAP_NET_RAW | Use raw sockets | Medium |
| CAP_SYS_ADMIN | Perform admin operations | Critical |
| CAP_SYS_PTRACE | Trace processes | Critical |
| CAP_SYS_MODULE | Load kernel modules | Critical |
Drop capabilities in Docker CLI:
# Drop all capabilities
docker run --cap-drop=ALL nginx
# Drop all, add specific
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE --cap-add=CHOWN nginx
# Drop specific dangerous capabilities
docker run --cap-drop=SYS_ADMIN --cap-drop=NET_RAW nginx
Drop capabilities in Docker Compose:
services:
app:
image: nginx
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWN
Drop capabilities in Dockerfile:
FROM nginx:alpine
# Document required capabilities
# Required: NET_BIND_SERVICE (port 80)
# Run with: docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE
CMD ["nginx", "-g", "daemon off;"]
Run containers with read-only root filesystem:
# Run with read-only root
docker run --read-only nginx
# With tmpfs for temporary data
docker run --read-only --tmpfs /tmp --tmpfs /var/run nginx
# With specific writable volumes
docker run --read-only -v app-data:/data nginx
Docker Compose:
services:
app:
image: nginx
read_only: true
tmpfs:
- /tmp
- /var/run
- /var/cache/nginx
volumes:
- app-data:/data
Handle applications that need write access:
FROM nginx:alpine
# Create necessary directories
RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx && \
chown -R nginx:nginx /var/cache/nginx /var/run /var/log/nginx
# Run as non-root
USER nginx
CMD ["nginx", "-g", "daemon off;"]
Seccomp restricts system calls containers can make.
Use Docker’s default seccomp profile:
# Default profile is applied automatically
docker run nginx
# Specify custom profile
docker run --security-opt seccomp=/path/to/profile.json nginx
Create custom seccomp profile:
{
"defaultAction": "SCMP_ACT_ERRNO",
"archMap": [
{
"architecture": "SCMP_ARCH_X86_64",
"subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]
}
],
"syscalls": [
{
"names": ["accept", "accept4", "access", "alarm", "bind", "brk", "capget", "capset"],
"action": "SCMP_ACT_ALLOW"
}
]
}
Check container seccomp profile:
docker inspect --format '{{.HostConfig.SecurityOpt}}' container_id
AppArmor (Debian/Ubuntu):
# Check AppArmor status
sudo aa-status
# Run container with AppArmor profile
docker run --security-opt apparmor=docker-default nginx
# Run with custom profile
docker run --security-opt apparmor=/path/to/profile nginx
Create custom AppArmor profile:
# /etc/apparmor.d/docker-custom
#include <tunables/global>
profile docker-custom flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network inet tcp,
network inet udp,
network inet icmp,
deny network raw,
/usr/sbin/nginx ix,
/var/log/nginx/** rw,
}
# Load profile
sudo apparmor_parser -r /etc/apparmor.d/docker-custom
SELinux (RHEL/CentOS/Fedora):
# Check SELinux status
getenforce
# Run container with SELinux
docker run --security-opt label=type:container_t nginx
# Run with custom type
docker run --security-opt label=type:custom_container_t nginx
# Relabel volume mounts
docker run -v /host/data:/data:z nginx # Shared label
docker run -v /host/data:/data:Z nginx # Private label
Avoid mounting sensitive host directories:
# BAD - Never do this
docker run -v /:/host nginx
docker run -v /etc:/etc nginx
docker run -v /var/run/docker.sock:/var/run/docker.sock nginx
# GOOD - Mount only what's needed
docker run -v app-data:/data nginx
docker run -v ./config:/etc/nginx/conf.d:ro nginx
# BEST - Use named volumes
docker run -v app-data:/data:ro nginx
Mount as read-only when possible:
# Read-only mount
docker run -v ./config:/etc/nginx/conf.d:ro nginx
# Docker Compose
services:
app:
volumes:
- ./config:/etc/nginx/conf.d:ro
- app-data:/data
Never run containers as root unless absolutely necessary.
Dockerfile best practices:
FROM node:20-alpine
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Set ownership
WORKDIR /app
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Docker Compose:
services:
app:
image: myapp
user: "1001:1001"
# Or by name
# user: "appuser:appgroup"
Check running user:
# Check container user
docker exec container_id whoami
docker exec container_id id
# Check process user
docker top container_id
Prevent resource exhaustion attacks:
# Limit memory
docker run -m 512m --memory-swap=512m nginx
# Limit CPU
docker run --cpus="1.5" nginx
# Limit PIDs (prevent fork bombs)
docker run --pids-limit=100 nginx
# Combined
docker run -m 512m --cpus="1.5" --pids-limit=100 nginx
Docker Compose:
services:
app:
image: nginx
deploy:
resources:
limits:
cpus: '1.5'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
pids_limit: 100
Configure logging driver:
# Run with json-file logging
docker run --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3 nginx
# Run with syslog
docker run --log-driver=syslog --log-opt syslog-address=udp://syslog.example.com:514 nginx
# Run with journald
docker run --log-driver=journald nginx
Docker Compose:
services:
app:
image: nginx
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Enable Docker event logging:
# View Docker events
docker events
# Filter events
docker events --filter 'type=container' --filter 'event=start'
# Log to file
docker events >> /var/log/docker-events.log 2>&1 &
Audit Docker daemon with auditd:
# Install auditd
sudo apt install auditd
# Add Docker audit rules
sudo auditctl -w /usr/bin/docker -k docker
sudo auditctl -w /var/run/docker.sock -k docker
sudo auditctl -w /etc/docker/daemon.json -k docker
# View audit logs
sudo ausearch -k docker
Check running containers:
# List all containers with details
docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Command}}"
# Check container processes
docker top container_id
# Check container resource usage
docker stats
# Check container logs
docker logs container_id
Monitor for privilege escalation:
# Check for privileged containers
docker inspect --format '{{.Name}}: {{.HostConfig.Privileged}}' $(docker ps -q) | grep true
# Check for dangerous capabilities
docker inspect --format '{{.Name}}: {{.HostConfig.CapAdd}}' $(docker ps -q) | grep -E "SYS_ADMIN|SYS_PTRACE|NET_ADMIN"
# Check for root user
docker exec $(docker ps -q) whoami 2>/dev/null | grep root
Integrate Trivy in CI/CD:
# .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]
jobs:
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy
run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}
Use Docker Scout in CI/CD:
# .github/workflows/docker-scout.yml
name: Docker Scout
on: [push]
jobs:
scout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Docker Scout
uses: docker/scout-action@v1
with:
image: myapp:${{ github.sha }}
only-severities: critical,high
exit-code: true
| Control | Status | Notes |
|---|---|---|
| Docker socket protected | ☐ | Not mounted in containers |
| Docker group restricted | ☐ | Only trusted users |
| Rootless mode enabled | ☐ | Where feasible |
| TLS for remote API | ☐ | If remote access needed |
| Images from trusted sources | ☐ | Official/verified only |
| Image versions pinned | ☐ | No latest tags |
| Vulnerability scanning | ☐ | Trivy/Docker Scout |
| No privileged containers | ☐ | Unless absolutely necessary |
| Capabilities dropped | ☐ | Drop ALL, add minimum |
| Read-only root filesystem | ☐ | Where possible |
| Running as non-root | ☐ | All containers |
| Resource limits set | ☐ | Memory, CPU, PIDs |
| Seccomp/AppArmor enabled | ☐ | Default or custom |
| Logging configured | ☐ | json-file with rotation |
| Unused images cleaned | ☐ | Regular pruning |
latest tag - Pin to specific versions/, /etc, /var/run/docker.sockCheck Docker security configuration:
# Check rootless mode
docker info | grep -i rootless
# Check security options
docker info | grep -E "Security Options|Cgroup"
# Check Docker socket permissions
stat -c "%a %U:%G" /var/run/docker.sock
# List Docker group members
getent group docker
# Check for privileged containers
docker inspect --format '{{.Name}}: {{.HostConfig.Privileged}}' $(docker ps -q)
# Check container capabilities
docker inspect --format '{{.Name}}: {{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}}' $(docker ps -q)
# Check container user
docker exec $(docker ps -q) whoami 2>/dev/null
Run secure container:
docker run -d \
--name secure-app \
--user 1001:1001 \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--read-only \
--tmpfs /tmp \
--tmpfs /var/run \
--pids-limit 100 \
--memory 512m \
--cpus="1.0" \
--security-opt seccomp=default \
--security-opt apparmor=docker-default \
--log-driver=json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
myapp:1.0