Skip to content
Containers

Container Security: How to Harden Your Docker Images

Harden Docker containers with minimal base images, non-root users, dropped capabilities, read-only filesystems, CVE scanning in CI, and Kubernetes Pod Security Standards.

A
Abhishek Patel11 min read

Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

Container Security: How to Harden Your Docker Images
Container Security: How to Harden Your Docker Images

How a Single Curl Call Became Cluster-Admin

A red team exercise I sat in on last year ended in 47 minutes. The target was a well-run fintech: hardened VPC, least-privilege IAM, SSO-gated bastion, PagerDuty on every suspicious API call. The attacker got in through a webhook handler that had an unvalidated SSRF. That alone should not have mattered. It mattered because of four container-security choices.

  1. The container was running as root inside the pod (USER never set in the Dockerfile).
  2. The service account had cluster-admin because the team had copy-pasted an example during a migration three sprints earlier.
  3. The pod had hostNetwork: true so it could forward metrics; no one had audited the implications.
  4. The base image was ubuntu:20.04, so curl, bash, wget, and a working apt were already on disk.

The SSRF was used to hit the Kubernetes API server from inside the pod. The cluster-admin token was lifted. A second curl exfiltrated the entire cluster's secrets via the API. 47 minutes, end-to-end, and every step was a default that nobody had changed.

Container security is not a single tool. It is the compound effect of half a dozen small choices: the base image, the user ID, the dropped capabilities, the read-only filesystem, the RBAC binding, the scanning pipeline, the secrets pattern. Get them all right and the same SSRF is a forgettable 500 error. Get any two wrong and you get the 47-minute story. This guide walks each layer in the order you should attack it, with the exact Dockerfile, Kubernetes, and CI configurations to steal.

Hardening Your Docker Images: Step by Step

  1. Start with a minimal base image -- Alpine (7 MB), distroless (2 MB), or scratch (0 MB) instead of ubuntu or debian
  2. Pin your base image digest -- use FROM alpine:3.19@sha256:abc123... instead of floating tags
  3. Create a non-root user and switch to it with USER
  4. Drop all Linux capabilities and add back only what you need
  5. Set the filesystem to read-only and mount writable tmpfs where needed
  6. Scan for CVEs in CI before the image reaches any registry
  7. Never store secrets in the image -- use build-time secrets or runtime injection

Minimal Base Images: Your First Line of Defense

Every package in your base image is a potential attack vector. A standard Ubuntu image has hundreds of packages, most of which your application never uses. Each one can have vulnerabilities.

Base ImageSizeTypical CVEsShellPackage Manager
ubuntu:22.0477 MB50-100+bashapt
debian:bookworm-slim74 MB30-80bashapt
alpine:3.197 MB0-5ashapk
gcr.io/distroless/static2 MB0-2NoneNone
scratch0 MB0NoneNone

Pro tip: Distroless images from Google are purpose-built for security. They contain only your application and its runtime dependencies -- no shell, no package manager, no curl. If an attacker gets code execution, they can't easily drop to a shell or download tools.

For reference: container security encompasses the practices, tools, and configurations used to protect containerised applications across their lifecycle -- image build, registry storage, runtime execution, and orchestration. It covers image hardening, vulnerability scanning, access control, network segmentation, and kernel-level isolation via seccomp, AppArmor, and capability filtering.

Running as Non-Root

By default, containers run as root (UID 0). This is the single most common security misconfiguration. If an attacker exploits a vulnerability in your app, they have root access inside the container -- and with certain misconfigurations, that can mean root on the host.

FROM node:20-alpine

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm ci --production

# Switch to non-root before CMD
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]

Verifying Non-Root at Runtime

# Check which user the container is running as
docker exec my-container whoami
# Should NOT output "root"

# Check the process UID
docker exec my-container id
# uid=1000(appuser) gid=1000(appgroup)

Dropping Linux Capabilities

Linux capabilities split root's power into granular permissions. By default, Docker grants containers a subset of capabilities, but it's still more than most apps need. The secure approach: drop all capabilities and add back only the ones your application requires.

# Drop all capabilities, add back only NET_BIND_SERVICE
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE my-app:latest
# In Kubernetes Pod spec
securityContext:
  capabilities:
    drop:
      - ALL
    add:
      - NET_BIND_SERVICE
  runAsNonRoot: true
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false

Watch out: Never run containers with --privileged in production. This flag gives the container ALL capabilities, access to all host devices, and disables most security mechanisms. A privileged container can trivially escape to the host.

Read-Only Root Filesystem

A read-only root filesystem prevents attackers from writing malicious binaries, scripts, or configuration changes to the container's filesystem. Most applications only need to write to specific directories like /tmp or a data directory.

# Docker: read-only root with writable /tmp
docker run --read-only --tmpfs /tmp:rw,noexec,nosuid my-app:latest
# Kubernetes: read-only root with emptyDir for /tmp
containers:
- name: app
  image: my-app:latest
  securityContext:
    readOnlyRootFilesystem: true
  volumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
- name: tmp
  emptyDir: {}

CVE Scanning in CI

Every image you build should be scanned for known vulnerabilities before it reaches a registry. The goal is to catch CVEs early -- in the pull request, not in production.

ToolTypeCostCI IntegrationDatabase
TrivyOSSFreeGitHub Actions, GitLab CIMultiple (NVD, Alpine, etc.)
GrypeOSSFreeGitHub ActionsAnchore feed
Snyk ContainerSaaSFree tier / $25+/dev/moAll major CISnyk Vulnerability DB
Docker ScoutSaaSIncluded with Docker subDocker CLI nativeMultiple sources
AWS ECR ScanningCloudFree (basic) / Inspector pricingNative to ECRClair / Inspector
# GitHub Actions: Scan with Trivy
- name: Scan image for vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: my-app:latest
    format: table
    exit-code: 1
    severity: CRITICAL,HIGH

Pro tip: Set exit-code: 1 in your scanner config to fail the CI pipeline when critical vulnerabilities are found. A scan that only reports but never blocks is just security theater.

Secrets Management

Secrets in Docker images are a common and serious mistake. Once a secret is in an image layer, anyone who pulls the image can extract it -- even if you delete the file in a later layer.

What NOT to Do

# WRONG: Secret baked into the image
ENV DATABASE_URL=postgres://admin:password@db:5432/mydb
COPY .env /app/.env

What to Do Instead

  • Build-time secrets (Docker BuildKit): RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
  • Runtime environment variables: Pass via docker run -e or Kubernetes Secrets
  • External secret stores: HashiCorp Vault, AWS Secrets Manager ($0.40/secret/month), or SOPS for encrypted files in Git

Kernel-Level Security: seccomp and AppArmor

Beyond container configuration, Linux kernel security modules provide additional isolation:

seccomp Profiles

Seccomp restricts which system calls a container can make. Docker ships a default profile that blocks about 44 syscalls, but you can create custom profiles for tighter control.

# Run with Docker's default seccomp profile (enabled by default)
docker run --security-opt seccomp=default my-app:latest

# Run with a custom profile
docker run --security-opt seccomp=custom-profile.json my-app:latest

AppArmor Profiles

AppArmor provides mandatory access control, restricting what files and capabilities a process can access. Docker applies a default AppArmor profile to all containers on systems where AppArmor is available.

Kubernetes Pod Security Standards

Kubernetes v1.25+ replaced PodSecurityPolicy with Pod Security Standards (PSS) enforced via the Pod Security Admission controller. Three built-in levels:

LevelWhat It AllowsUse Case
PrivilegedNo restrictionsSystem-level workloads (CNI, storage drivers)
BaselineBlocks known privilege escalationsMost application workloads
RestrictedHardened, follows current best practicesSecurity-sensitive workloads
# Enforce restricted policy on a namespace
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

Pricing: Security Tools and Services

ToolFree TierPaid Pricing
TrivyFully free (OSS)N/A
Snyk Container200 tests/monthFrom $25/dev/month (Team)
HashiCorp VaultOSS self-hostedHCP Vault from $0.03/hr
AWS Secrets ManagerNone$0.40/secret/month + $0.05/10K API calls
Sysdig SecureNoneCustom pricing (runtime security)

Failure Modes: The Misconfigurations That Get Exploited First

Defensive controls are a chain. These are the links that tend to snap first in real audits I have done.

Docker Socket Mounted Into a Container

A CI runner or a "deploy tool" needs to spin up containers, so someone mounts /var/run/docker.sock into the pod. Root inside that pod is root on the node, trivially, because the socket is the Docker daemon API. Never do this. Use rootless Docker, Kaniko, Buildah, or sysbox. If you absolutely must, isolate those pods onto a dedicated node pool with no production workloads.

Cluster-Admin ServiceAccount Tokens Mounted Automatically

Kubernetes mounts a ServiceAccount token into every pod by default at /var/run/secrets/kubernetes.io/serviceaccount/token. If that SA has cluster-admin (a surprisingly common copy-paste), any code execution in the pod equals cluster compromise. Set automountServiceAccountToken: false on every pod that does not explicitly need the API, and RBAC-audit the ones that do.

Unscoped hostPath Volume Mounts

A pod with hostPath: / (or /etc, or /var/lib/kubelet) is a container escape waiting for a compromise. The pod can read the host's kubeconfig, the kubelet's credentials, or write into systemd unit files. Block hostPath with a Pod Security admission or Kyverno policy unless the use case is absolutely required (CSI drivers, node agents) and the path is tightly scoped.

Privileged Init Containers

Teams set securityContext.privileged: true on the init container because "it only runs once." An init container with privileged is the same escape surface as a privileged main container -- the attacker just needs code execution during that window. Audit for privileged: true across all containers, including initContainers and ephemeralContainers.

Image Pull Secrets in Base64 on GitHub

A Docker config.json with pull credentials gets committed to the repo, base64-encoded, as a Kubernetes Secret manifest. Anyone with repo read access has the registry credentials. Use IRSA (AWS), Workload Identity (GCP), or an external secrets operator -- never check registry creds into source control.

Kubernetes Network Policy: The Layer Everybody Skips

Hardening the container is necessary but insufficient. Kubernetes defaults to flat pod-to-pod networking: a compromised front-end can hit the database port directly. NetworkPolicies fix this -- but only if you actually write them.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-to-db
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: postgres
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: api
      ports:
        - protocol: TCP
          port: 5432

Start with default-deny-all and allow explicitly. The CNI must actually enforce NetworkPolicy (Calico, Cilium, Weave) -- the AWS VPC CNI did not until version 1.14, and some lightweight CNIs still do not. Check kubectl get cnis -o wide and confirm policy support before relying on it.

Frequently Asked Questions

Is running as root inside a container dangerous if the container is isolated?

Yes. Container isolation isn't perfect -- kernel vulnerabilities, misconfigured volume mounts, or the Docker socket can allow container escapes. Running as root inside the container means root on the host if isolation breaks. Non-root is defense in depth: even if isolation fails, the attacker has limited privileges.

Do I need to scan images if I'm using Alpine or distroless?

Yes, always. Alpine has fewer CVEs than Ubuntu, but it still has them. Your application dependencies (npm packages, Python libraries, Go modules) also have vulnerabilities that base-image choice doesn't affect. Scan everything, every build.

What's the difference between seccomp and AppArmor?

Seccomp filters system calls -- it controls what operations a process can ask the kernel to do. AppArmor controls file and resource access -- what paths a process can read, write, or execute. They complement each other. Docker enables default profiles for both on supported systems.

Should I use Kubernetes Pod Security Standards or a third-party policy engine?

Start with Pod Security Standards -- they're built in and cover the most important security baselines. If you need more granular control (custom policies, mutation, audit logging), add OPA Gatekeeper or Kyverno. Most teams don't need a policy engine until they have multiple teams deploying to the same cluster.

How do I handle secrets in Kubernetes?

Kubernetes Secrets are base64-encoded, not encrypted, by default. For production, enable encryption at rest in etcd, or better yet, use an external secret store (Vault, AWS Secrets Manager) with the External Secrets Operator to sync secrets into the cluster. Never commit secrets to Git in plain text.

Can Docker Scout replace Trivy or Snyk?

Docker Scout is a solid choice if you're already paying for Docker Desktop subscriptions. It integrates natively with docker build and provides remediation advice. However, Trivy is free, faster in CI, and supports more artifact types. For most teams, Trivy is the pragmatic default.

Conclusion

Container security isn't optional, and it's not something you bolt on after deployment. Use minimal base images, run as non-root, drop capabilities, set read-only filesystems, scan in CI, and manage secrets externally. These six practices eliminate the vast majority of container-related vulnerabilities. Start with the Dockerfile changes -- they take five minutes and make the biggest difference.

A

Written by

Abhishek Patel

Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.

Related Articles

Enjoyed this article?

Get more like this in your inbox. No spam, unsubscribe anytime.

Comments

Loading comments...

Leave a comment

Stay in the loop

New articles delivered to your inbox. No spam.