Top Docker Security Best Practices Every Dev Must Know
Top Docker Security Best Practices Every Dev Must Know
Docker's default configuration prioritizes developer convenience over security. Containers run as root by default, images pull from public registries without verification, and the Docker daemon has privileged access to the host system. This design works fine for local development but creates serious vulnerabilities in production. A compromised container can access other containers, leak secrets through image layers, or exploit kernel vulnerabilities to escape isolation and compromise the host.
Security isn't a feature you add after building containerized applications—it's architectural decisions made from the beginning. This guide covers the essential security practices that prevent common attack vectors: minimizing attack surface through slim images, implementing proper isolation boundaries, protecting secrets throughout the container lifecycle, scanning for vulnerabilities, and hardening the container runtime. These aren't theoretical recommendations—they're defenses against attacks that have successfully compromised production systems.
We'll examine image security, runtime hardening, network isolation, secret management, vulnerability scanning, and the principle of least privilege applied to containers.
Understanding the Container Security Model
Containers provide process isolation through Linux kernel features—namespaces, cgroups, and capabilities. Unlike virtual machines with separate kernels, containers share the host kernel. This means kernel vulnerabilities potentially affect all containers on a host. Container isolation is strong but not absolute—it's a process boundary enforced by the kernel, not a hardware boundary like VM hypervisors provide.
The security model has multiple layers: image supply chain (what goes into containers), build-time security (how images are constructed), runtime security (how containers execute), network security (how containers communicate), and orchestration security (how platforms manage containers). Weaknesses in any layer compromise the entire stack.
The Root Problem with Running as Root
By default, processes inside containers run as root (UID 0). If an attacker exploits an application vulnerability and gains code execution, they have root access inside the container. While namespace isolation prevents direct host access, several container escape vulnerabilities have allowed root containers to break out. Even without escape, root access inside containers enables privilege escalation, file system manipulation, and lateral movement to other containers.
Running as a non-root user dramatically reduces risk. If an attacker compromises a non-root container, their capabilities are limited. They can't install packages, modify system files, or bind to privileged ports without additional escalation.
Building Secure Images
The attack surface of a container is determined by what's inside the image. Every package, library, and binary is potential exploit surface. Minimal images with only required components reduce exposure.
Use Minimal Base Images
Start with the smallest viable base image. Alpine Linux images are 5-10x smaller than Debian-based images:
FROM node:18-alpine
# vs
FROM node:18
The Alpine variant contains fewer packages—no bash, no GNU utilities, minimal C library. Fewer components mean fewer CVEs, faster vulnerability scanning, and reduced download time. Google's distroless images take minimalism further, removing all package managers and shells:
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs18-debian11
COPY --from=builder /app /app
WORKDIR /app
CMD ["server.js"]
Distroless images contain only your application and its runtime dependencies. No shell means many attack techniques fail immediately—attackers can't execute shell commands even if they achieve code execution.
Never Include Secrets in Images
A common mistake is copying secrets into images during build:
# WRONG - secrets persist in image layers
FROM node:18-alpine
COPY .env /app/.env
RUN npm install
RUN rm .env # Too late - .env is in a previous layer
Docker images are layered filesystems. Each instruction creates a layer. Even if you delete files in later layers, they're accessible by examining earlier layers. Anyone with access to the image can extract secrets from historical layers.
Use BuildKit secrets for build-time secrets that don't persist:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install
COPY . .
Build with:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
The .npmrc file mounts temporarily during the RUN instruction and doesn't persist in any layer. For runtime secrets, inject them via environment variables, secret volumes, or external secret managers—never bake them into images.
Multi-Stage Builds to Remove Build Dependencies
Build tools and dependencies increase attack surface. Multi-stage builds compile code in one stage and copy only runtime artifacts to the final image:
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM alpine:latest
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
USER nobody
CMD ["/server"]
The final image contains the compiled binary and CA certificates—no Go compiler, no source code, no build dependencies. This reduces the image from 300+ MB to under 20 MB and eliminates vulnerabilities in build tools.
docker history myimage:tag to inspect image layers and their contents. This reveals what's in each layer and helps identify secrets or unnecessary files that persisted through build stages.
Implementing Least Privilege with Non-Root Users
Create and use a dedicated non-root user in every production Dockerfile:
FROM node:18-alpine
# Create user with specific UID/GID
RUN addgroup -g 1001 appgroup && \
adduser -D -u 1001 -G appgroup appuser
WORKDIR /app
# Copy files with correct ownership
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
The --chown flag ensures files are owned by the application user, preventing permission issues. The USER instruction makes all subsequent commands and the container's main process run as that user.
Handling Privileged Ports
Ports below 1024 require root access to bind. Instead of running as root to bind port 80, use port mapping:
docker run -p 80:3000 myapp
This maps host port 80 to container port 3000. The application inside the container listens on 3000 (unprivileged) while appearing on port 80 externally. In Kubernetes, Services handle this mapping automatically.
Runtime Security Hardening
Read-Only Root Filesystem
Most applications don't need to write to their filesystem—they write to databases, object storage, or specific volume mounts. Running with a read-only root filesystem prevents attackers from modifying binaries, dropping shells, or persisting malware:
docker run --read-only --tmpfs /tmp myapp
The --tmpfs /tmp flag provides a writeable temporary directory while keeping the rest of the filesystem read-only. In Kubernetes:
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
image: myapp:v1
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
If your application fails with read-only filesystem, identify which paths it writes to and mount them as volumes. Common writable paths include /tmp, /var/cache, and application-specific log or data directories.
Dropping Capabilities
Linux capabilities grant specific privileges to processes. Containers run with a default capability set—more than most applications need. Drop all capabilities and add back only required ones:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
In Kubernetes:
securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
Start with --cap-drop=ALL and run your application. If it fails, identify which capability it needs (usually visible in error messages) and add it specifically. Most applications need zero capabilities.
Preventing Privilege Escalation
Disable privilege escalation to prevent processes from gaining additional privileges through setuid binaries:
docker run --security-opt=no-new-privileges myapp
In Kubernetes:
securityContext:
allowPrivilegeEscalation: false
This blocks a common attack vector where vulnerabilities in setuid binaries grant elevated access.
Resource Limits to Prevent DoS
Containers without resource limits can consume all host resources, creating denial-of-service conditions. Set memory and CPU limits:
docker run -m 512m --cpus=1 myapp
In Kubernetes:
resources:
limits:
memory: "512Mi"
cpu: "1000m"
requests:
memory: "256Mi"
cpu: "500m"
Limits prevent a single compromised or misbehaving container from affecting other workloads on the same host.
Vulnerability Scanning and Image Management
Container images inherit vulnerabilities from base images, language runtimes, and installed packages. Regular scanning identifies known CVEs before they reach production.
Scanning with Trivy
Trivy is a comprehensive vulnerability scanner for containers, filesystems, and configuration:
docker run aquasec/trivy image myapp:v1.2.3
Trivy downloads vulnerability databases and scans all packages in the image, reporting CVEs with severity ratings. Integrate scanning into CI pipelines to block vulnerable images:
trivy image --exit-code 1 --severity CRITICAL,HIGH myapp:v1.2.3
This exits with status 1 if critical or high-severity vulnerabilities exist, failing the build. Configure thresholds based on your risk tolerance—some teams block on medium severity, others only on critical.
Using Signed and Verified Images
Docker Content Trust (DCT) ensures images are signed and haven't been tampered with. Enable DCT to only pull signed images:
export DOCKER_CONTENT_TRUST=1
docker pull myapp:v1.2.3
If the image isn't signed, the pull fails. Sign images with notary:
docker trust sign myapp:v1.2.3
This requires setting up a notary server or using a registry with built-in signing (like Docker Hub with DCT enabled). Signing prevents man-in-the-middle attacks and guarantees images come from trusted sources.
Private Registries and Access Controls
Never store production images in public registries. Use private registries with authentication and authorization:
- Docker Hub private repositories
- AWS Elastic Container Registry (ECR)
- Google Artifact Registry
- Azure Container Registry
- Self-hosted Harbor or GitLab Container Registry
Configure role-based access control to limit who can push images. Separate registries or repositories for development, staging, and production. Only CI systems and authorized personnel should push to production registries.
Secret Management Best Practices
Hardcoded secrets in environment variables or config files are a leading cause of container breaches. Secrets need encryption at rest, access controls, rotation, and audit logging.
External Secret Management
Use dedicated secret managers instead of environment variables:
- HashiCorp Vault
- AWS Secrets Manager or Parameter Store
- Google Secret Manager
- Azure Key Vault
Applications fetch secrets at runtime from the secret manager. This enables centralized rotation, access auditing, and encryption. Example with AWS Secrets Manager:
const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager();
async function getSecret(secretName) {
const data = await secretsManager.getSecretValue({ SecretId: secretName }).promise();
return JSON.parse(data.SecretString);
}
const dbCreds = await getSecret('prod/database/credentials');
Kubernetes Secrets with External Secrets Operator
Kubernetes Secrets store sensitive data but are only base64-encoded, not encrypted by default. Use the External Secrets Operator to sync secrets from external managers into Kubernetes:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secretsmanager
kind: SecretStore
target:
name: app-secrets
data:
- secretKey: db-password
remoteRef:
key: prod/database/password
This fetches secrets from AWS Secrets Manager and creates Kubernetes secrets automatically. Changes in the external manager propagate to pods, enabling secret rotation without redeploying.
Never Log Secrets
Secrets in logs are a common leak vector. Sanitize logs before writing:
function sanitizeLog(obj) {
const sanitized = { ...obj };
const sensitiveKeys = ['password', 'token', 'apiKey', 'secret'];
for (const key in sanitized) {
if (sensitiveKeys.some(k => key.toLowerCase().includes(k))) {
sanitized[key] = '[REDACTED]';
}
}
return sanitized;
}
logger.info(sanitizeLog({ username: 'user', password: 'secret123' }));
// Logs: { username: 'user', password: '[REDACTED]' }
Configure logging libraries to automatically redact sensitive fields. Review logs periodically to ensure secrets aren't leaking.
Network Security and Isolation
Network Segmentation
By default, containers on the same Docker network can communicate freely. Implement network policies to restrict traffic to only necessary connections. In Kubernetes:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-network-policy
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 3000
egress:
- to:
- podSelector:
matchLabels:
app: database
ports:
- protocol: TCP
port: 5432
This policy allows API pods to receive traffic only from frontend pods on port 3000, and allows API pods to connect only to database pods on port 5432. All other traffic is denied.
Disable Inter-Container Communication
For Docker standalone, disable inter-container communication on the default bridge network:
docker network create --driver bridge --opt com.docker.network.bridge.enable_icc=false isolated-network
Containers on this network can't communicate with each other unless explicitly linked, reducing lateral movement risk.
TLS for Inter-Service Communication
Encrypt traffic between services with mutual TLS (mTLS). Service meshes like Istio, Linkerd, or Consul automate mTLS:
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
spec:
mtls:
mode: STRICT
This enforces mTLS for all service-to-service communication in the mesh, preventing eavesdropping and man-in-the-middle attacks.
Securing the Docker Daemon
The Docker daemon runs as root and has extensive host privileges. Securing daemon access is critical—anyone with Docker socket access effectively has root on the host.
Limit Docker Socket Access
Never mount the Docker socket into containers unless absolutely necessary:
# DANGEROUS - gives container full control over host
docker run -v /var/run/docker.sock:/var/run/docker.sock myapp
This grants the container ability to start/stop other containers, access volumes, and manipulate the host. Only use for specific tools like CI agents or monitoring systems that require it, and treat those containers as fully privileged.
Enable User Namespaces
User namespaces remap container UIDs to different host UIDs. Root inside a container (UID 0) maps to an unprivileged UID on the host (like 100000). Enable user namespace remapping:
sudo nano /etc/docker/daemon.json
Add:
{
"userns-remap": "default"
}
Restart Docker:
sudo systemctl restart docker
Containers now run with remapped UIDs. Even if an attacker escapes the container, they have no host privileges. The downside is complexity with volume permissions—files created by containers are owned by remapped UIDs on the host.
Audit Logging
Enable audit logging to track who's doing what with Docker:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"log-level": "info"
}
Integrate with centralized logging systems (CloudWatch, Splunk, ELK stack) to monitor Docker daemon operations and detect suspicious activity.
Compliance and Security Benchmarks
CIS Docker Benchmark
The Center for Internet Security publishes a Docker security benchmark with 100+ recommendations. Use Docker Bench for Security to audit compliance:
docker run --rm --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /var/lib:/var/lib \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /etc:/etc \
docker/docker-bench-security
This scans your Docker host configuration and running containers against CIS benchmarks, reporting findings with severity levels. Address critical and high findings first.
Pod Security Standards in Kubernetes
Kubernetes Pod Security Standards define three levels of security policy: privileged, baseline, and restricted. Enforce restricted mode for maximum security:
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
Restricted mode requires non-root users, read-only root filesystems, dropped capabilities, and other hardening measures. Pods that don't meet these requirements are rejected.
Runtime Security Monitoring
Falco for Runtime Threat Detection
Falco monitors container behavior for suspicious activity—unexpected processes, file access, network connections, or privilege escalation attempts:
kubectl apply -f https://raw.githubusercontent.com/falcosecurity/falco/master/deploy/kubernetes/falco-daemonset.yaml
Falco runs on every node and alerts on anomalies based on rules. Example rule detecting shell spawning in containers:
- rule: Shell spawned in container
desc: Detect shell spawned in container
condition: container and proc.name in (bash, sh, zsh)
output: Shell spawned (user=%user.name container=%container.name)
priority: WARNING
Configure Falco to send alerts to Slack, PagerDuty, or SIEM systems for rapid incident response.
AppArmor and SELinux Profiles
Mandatory Access Control (MAC) systems like AppArmor or SELinux provide additional isolation layers. Load custom profiles that define exactly what containers can access:
docker run --security-opt apparmor=docker-default myapp
In Kubernetes, specify AppArmor profiles in pod annotations:
annotations:
container.apparmor.security.beta.kubernetes.io/app: runtime/default
Create custom profiles for applications with specific requirements, restricting file access, network capabilities, and system calls beyond what namespaces provide.
Supply Chain Security
Base Image Verification
Only use base images from trusted sources. Verify official images through Docker Hub verified publishers or use your own base images built from verified sources. Scan base images before using them:
trivy image node:18-alpine
Monitor base images for new vulnerabilities and rebuild your images when base images update with security patches.
Dependency Management
Application dependencies (npm packages, Python wheels, Go modules) are attack vectors. Use lock files to pin exact versions:
- Node.js:
package-lock.json - Python:
requirements.txtwith hashes (pip freeze) - Go:
go.sum
Scan dependencies for vulnerabilities with tools like npm audit, Snyk, or GitHub Dependabot. Update dependencies regularly and test thoroughly—some updates introduce breaking changes.
SBOM Generation
Software Bill of Materials (SBOM) lists all components in an image. Generate SBOMs for auditing and compliance:
syft myapp:v1.2.3 -o json > sbom.json
SBOMs help track which images are affected when new vulnerabilities are disclosed in specific versions of dependencies.
Frequently Asked Questions
Is running containers as non-root enough for production security?
Non-root is essential but not sufficient. You also need read-only filesystems, dropped capabilities, resource limits, vulnerability scanning, and secret management. Security is layered—no single practice provides complete protection. Non-root is the most impactful single change, but combine it with other hardening measures.
How often should I scan images for vulnerabilities?
Scan on every build in CI pipelines to catch vulnerabilities before deployment. Additionally, scan running images regularly (daily or weekly) because new CVEs are disclosed constantly. Images that were clean yesterday might have critical vulnerabilities today as researchers discover flaws in dependencies.
What's the performance impact of security hardening?
Minimal for most practices. Non-root users, read-only filesystems, and capability dropping have negligible overhead. User namespace remapping adds slight overhead. The biggest "performance cost" is operational complexity—learning how to configure hardening correctly and troubleshooting applications that assume root privileges or writeable filesystems.
Should I use distroless images in production?
Distroless images provide excellent security through minimal attack surface but complicate debugging since there's no shell. Use distroless for applications where security is paramount and you have robust external debugging tools (structured logging, distributed tracing, metrics). Keep debug variants available for troubleshooting non-production issues.
How do I handle secrets for applications that expect them as environment variables?
Fetch secrets from secret managers and set them as environment variables at container startup, not in image definitions or docker-compose files. In Kubernetes, use the External Secrets Operator to sync secrets into Kubernetes Secrets, then reference them as environment variables. The key is secrets never appear in version control or image layers.
What's the difference between Docker Content Trust and image signing?
Docker Content Trust is Docker's implementation of image signing using Notary. When DCT is enabled, images must be signed with cryptographic keys before push and signature is verified on pull. This ensures images haven't been tampered with between build and deployment. It's an extra layer beyond HTTPS registry connections.
Can I use the same Dockerfile for development and production?
Yes, using multi-stage builds with different targets. The development target might include debugging tools, run as root for convenience, and have relaxed security. The production target drops capabilities, runs as non-root, uses minimal base images, and includes only runtime dependencies. Build different targets based on environment.
How do I troubleshoot containers running with read-only filesystems?
Check logs for permission denied errors that indicate write attempts. Identify which paths your application writes to and mount them as writable volumes (emptyDir in Kubernetes or tmpfs in Docker). Common writable paths include /tmp, /var/cache, application log directories, and upload directories. Applications should externalize state to databases or object storage, not local filesystems.
What happens if I enable user namespace remapping with existing volumes?
Existing volumes will have permission issues because file ownership UIDs no longer match container UIDs. You'll need to chown files on volumes to the remapped UID range or recreate volumes. This is why user namespace remapping is easier to enable from the beginning rather than retrofitting onto existing systems.
How do I know which capabilities my application needs?
Start with --cap-drop=ALL and run your application. If it fails, the error message usually indicates which capability is missing (e.g., "bind: permission denied" suggests NET_BIND_SERVICE). Add that specific capability and test again. Tools like amicontained can inspect running containers to show which capabilities are actually being used.
Conclusion
Docker security requires intentional design across the container lifecycle—from how images are built to how they execute in production. The default Docker configuration is insecure by design, optimized for developer productivity over production safety. Production deployments need non-root users, minimal images, vulnerability scanning, secret management, network isolation, and runtime monitoring.
Security isn't a binary state but a spectrum of hardening measures, each reducing specific attack vectors. Start with high-impact, low-complexity practices: non-root users, vulnerability scanning in CI, and read-only filesystems. Layer on additional controls as your security maturity grows—network policies, secret managers, runtime threat detection, and comprehensive compliance auditing.
The goal isn't perfect security (which doesn't exist) but raising the cost of successful attacks high enough that attackers target easier victims. Defense in depth means breaking through one layer doesn't compromise the entire system. Build that depth into your containers from day one.