Introduction
Container security is not automatic — a Docker container running as root with no resource limits and a publicly pulled image is a significant security risk. Enterprise container deployments require deliberate security hardening at every layer: image security, runtime security, network isolation, and secret management. This guide teaches practical container security for production environments.
Image Security
The most common container security mistake: using unvetted base images.
# Bad: using latest (unpinned) base image
FROM ubuntu:latest # Could change any day
# Bad: running as root (default)
COPY app /app
CMD ["/app"]
# Good: pinned version, non-root user
FROM ubuntu:22.04@sha256:2b7412e6465c3c7fc5bb21d3e6f1917c167358449fecac8176c6e496e5c1f05f
# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Set proper ownership
COPY --chown=appuser:appuser . /app
WORKDIR /app
# Drop ALL Linux capabilities, only add what's needed
USER appuser
# Don't run as PID 1 (use tini or dumb-init)
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/server"]Scanning Images for Vulnerabilities
# Trivy: scan for CVEs, misconfigurations, secrets
trivy image company/myapp:v1.2.3
# Output example:
# Total: 15 (CRITICAL: 2, HIGH: 5, MEDIUM: 8)
# Library Vulnerability Severity Installed Fixed
# libssl1.1 CVE-2021-3711 CRITICAL 1.1.1k 1.1.1l
# Integrate into CI pipeline
trivy image --exit-code 1 --severity CRITICAL company/myapp:latest
# Fails pipeline if CRITICAL CVEs found
# Scan Dockerfile for misconfigurations
trivy config ./Dockerfile
# Snyk alternative
snyk container test company/myapp:latest
# Docker Scout (built into Docker Desktop)
docker scout cves company/myapp:latestRuntime Security with Seccomp and AppArmor
# Seccomp: restrict system calls available to container
# Create seccomp profile
cat > /etc/docker/seccomp/myapp.json << 'EOF'
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": [
"accept4", "bind", "brk", "clock_gettime",
"close", "connect", "epoll_create1", "epoll_ctl",
"epoll_wait", "exit_group", "fcntl", "fstat",
"futex", "getpid", "listen", "mmap", "mprotect",
"munmap", "nanosleep", "poll", "read", "recvfrom",
"sendto", "set_robust_list", "setsockopt", "sigaltstack",
"socket", "stat", "write"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
EOF
# Run with seccomp profile
docker run --security-opt seccomp=/etc/docker/seccomp/myapp.json company/myapp:latest
# AppArmor profile (Ubuntu/Debian)
# /etc/apparmor.d/docker-myapp
profile docker-myapp flags=(attach_disconnected, mediate_deleted) {
network tcp,
network udp,
deny network raw,
file /app/** r,
file /tmp/** rw,
deny /etc/shadow r,
deny @{PROC}/sysrq-trigger rwmlk,
}
apparmor_parser -r -W /etc/apparmor.d/docker-myapp
docker run --security-opt apparmor=docker-myapp company/myapp:latestKubernetes Pod Security
# Pod Security Context: enforce non-root, read-only filesystem
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: company/myapp:v1.2.3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true # Immutable container filesystem
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # Only add what's needed
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "100m"
memory: "128Mi"
volumeMounts:
- name: tmp-dir
mountPath: /tmp # Writable tmp
volumes:
- name: tmp-dir
emptyDir: {}Network Security in Containers
# NetworkPolicy: deny all, allow only needed
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: app-network-policy
namespace: production
spec:
podSelector:
matchLabels:
app: myapp
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: nginx-ingress # Only ingress controller can reach app
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: postgres # App can only talk to postgres
ports:
- protocol: TCP
port: 5432
- to:
- namespaceSelector: {} # Allow DNS queries
ports:
- protocol: UDP
port: 53Secrets Management
# Never pass secrets as environment variables (visible in ps, docker inspect, logs)
docker run -e DATABASE_PASSWORD=secret123 myapp # BAD
# Better: Docker secrets (Swarm)
echo "secret123" | docker secret create db_password -
docker service create --secret db_password myapp
# Secret available at: /run/secrets/db_password
# Best: Vault Agent sidecar (see hashicorp-vault-secrets guide)
# Or: Kubernetes Secrets with external-secrets operator
kubectl create secret generic db-creds --from-literal=password=secret123 --namespace productionFalco: Runtime Threat Detection
# Falco detects suspicious container behavior
# /etc/falco/falco_rules.yaml
- rule: Shell in Container
desc: Detect shell execution in a container
condition: container and proc.name in (bash, sh, ash, zsh) and not proc.pname in (runc)
output: "Shell spawned in container (user=%user.name container=%container.name cmd=%proc.cmdline)"
priority: WARNING
- rule: Write below /etc
desc: Detect file writes to /etc
condition: container and fd.name startswith /etc and evt.type = write
output: "Write to /etc in container (container=%container.name file=%fd.name)"
priority: ERROR
- rule: Outbound Connection to Rare IP
desc: Detect unexpected outbound connections
condition: >
outbound and container and
not fd.sip in (allowed_outbound_destinations)
output: "Unexpected outbound connection (container=%container.name ip=%fd.sip)"
priority: WARNINGContainer security is defense-in-depth: start with non-root images and vulnerability scanning in CI, add resource limits and network policies in Kubernetes, and use runtime security tools to detect anomalies. No single control is sufficient — security comes from the combination.
