Why Container Security Cannot Be an Afterthought
Containers provide isolation, but they are not a security boundary by default. A misconfigured container can expose host filesystems, leak credentials, or provide an attacker with a foothold to move laterally across your infrastructure. The shared kernel model means a container escape affects every workload on the host.
At DevOpsVibe, we audit container environments regularly. These are the ten practices that make the biggest difference between a vulnerable deployment and a hardened one.
1. Use Minimal Base Images
Every package in your image is a potential attack surface. Start with the smallest image that works:
Before (vulnerable):
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip curl wget vim
COPY . /app
RUN pip3 install -r /app/requirements.txt
CMD ["python3", "/app/main.py"]
After (hardened):
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY . /app
WORKDIR /app
CMD ["main.py"]
The distroless image has no shell, no package manager, and no unnecessary utilities. If an attacker gains code execution, there is almost nothing to work with.
Image size comparison:
ubuntu:22.04with dependencies: ~450MBpython:3.12-slimmulti-stage with distroless: ~85MB
2. Never Run as Root
By default, Docker runs processes as root inside the container. This is dangerous because a container escape gives the attacker root on the host:
FROM node:20-slim
# Create a non-root user
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
RUN npm ci --only=production
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Verify at runtime:
docker run --rm my-app whoami
# Output: appuser
3. Scan Images for Vulnerabilities
Integrate image scanning into your CI/CD pipeline. We recommend Trivy for its speed and comprehensive database:
# Scan during build
trivy image --severity HIGH,CRITICAL --exit-code 1 my-app:latest
# Example output:
# my-app:latest (debian 12.4)
# Total: 0 (HIGH: 0, CRITICAL: 0)
Automate this in your pipeline:
# .github/workflows/security.yml
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: "1"
- name: Upload scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
Also scan your Dockerfiles for misconfigurations:
trivy config --severity HIGH,CRITICAL ./Dockerfile
4. Pin Image Digests, Not Just Tags
Tags are mutable. Someone can push a compromised image to python:3.12-slim and every build that pulls that tag gets the malicious version:
# Risky: tag can be overwritten
FROM python:3.12-slim
# Secure: digest is immutable
FROM python:3.12-slim@sha256:a3e58c29e4a3b692a57d0e68b4b9c4f2e63e547e1b3f0a8e93c2a4fb7d3e1c9a
Get the digest with:
docker inspect --format='{{index .RepoDigests 0}}' python:3.12-slim
5. Implement Read-Only Filesystems
Prevent runtime modification of the container filesystem. If your application needs to write, mount specific writable volumes:
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--volume /app/data:/data:rw \
my-app:latest
In Kubernetes:
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL
6. Manage Secrets Properly
Never bake secrets into images. They persist in layer history even if you delete them in a later layer:
Wrong:
# NEVER do this
ENV DATABASE_URL=postgres://admin:password@db:5432/myapp
COPY .env /app/.env
Right -- use build-time secrets (BuildKit):
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=db_url \
export DATABASE_URL=$(cat /run/secrets/db_url) && \
python manage.py collectstatic
docker buildx build --secret id=db_url,src=./db_url.txt -t my-app .
At runtime, use your orchestrator's secrets management:
# Kubernetes secret injection
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: database-url
7. Limit Container Resources
An unbounded container can consume all host resources, creating a denial-of-service condition:
docker run \
--memory=512m \
--memory-swap=512m \
--cpus=1.0 \
--pids-limit=256 \
--ulimit nofile=1024:1024 \
my-app:latest
In Kubernetes, always set resource requests and limits:
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "1000m"
8. Drop All Capabilities
Linux capabilities give fine-grained control over what a process can do. Drop all of them and add back only what you need:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE my-app:latest
Common capabilities and when you actually need them:
| Capability | Use Case |
|---|---|
NET_BIND_SERVICE | Bind to ports below 1024 |
CHOWN | Change file ownership (rarely needed) |
SYS_PTRACE | Debugging only, never in production |
SYS_ADMIN | Almost never -- this is nearly equivalent to root |
9. Use Multi-Stage Builds to Exclude Build Tools
Build dependencies like compilers, debuggers, and source code should never appear in your production image:
# Stage 1: Build
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# Stage 2: Production
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
The final image contains only the compiled binary and TLS certificates. No Go toolchain, no source code, no shell.
10. Enable Docker Content Trust and Sign Images
Ensure that only verified images run in your environment:
# Enable content trust
export DOCKER_CONTENT_TRUST=1
# Sign and push
docker trust sign myregistry.com/my-app:v1.2.3
# Verify
docker trust inspect myregistry.com/my-app:v1.2.3
For Kubernetes environments, use admission controllers like Kyverno or OPA Gatekeeper to enforce image policies:
# Kyverno policy: require signed images
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: Enforce
rules:
- name: verify-signature
match:
resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "myregistry.com/*"
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
Security Checklist
Before deploying any container to production, verify:
- Base image is minimal (distroless, Alpine, or scratch)
- Image is scanned for CVEs with zero HIGH/CRITICAL findings
- Container runs as non-root user
- Filesystem is read-only where possible
- No secrets baked into the image
- Resource limits are set
- All unnecessary capabilities are dropped
- Image tags are pinned to digests
- Images are signed and verified
- Network policies restrict container communication
Conclusion
Container security is not a single tool or a one-time audit. It is a set of practices embedded into every stage of your pipeline -- from the Dockerfile to the runtime configuration. The ten practices above address the most common attack vectors we see in production environments.
At DevOpsVibe, we help teams build secure container pipelines from the ground up. Whether you need a security audit of your existing setup or want to implement DevSecOps practices across your organization, our team has the expertise to get it done right. Contact us to start the conversation.