Container Security: Hardening Your Images and Runtime

Container Security: Hardening Your Images and Runtime

Containers changed the threat model for application security. A container isn’t a VM—the kernel is shared with the host, and container escapes, while not trivial, are a real attack class. Securing containers requires thinking about security at build time, image time, and runtime, with each layer complementing the others.

Layer 1: Secure Dockerfile Practices

Security starts in the Dockerfile. Every decision you make here affects the attack surface of every container you run.

Don’t Run as Root

The most common container security mistake: running as root inside the container. A process running as root inside a container that escapes the container context is root on the host. Always specify a non-root user:

FROM eclipse-temurin:21-jre-alpine

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

# Copy application
COPY --chown=appuser:appgroup target/app.jar /app/app.jar

# Switch to non-root user
USER appuser

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Use Minimal Base Images

Every package in your base image is potential attack surface. Use minimal base images:

# Unnecessary bloat
FROM ubuntu:22.04

# Better - JRE only
FROM eclipse-temurin:21-jre-alpine

# Best for statically compiled apps (Go, Rust)
FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]

Alpine Linux is a common choice—it’s small (~5MB), uses musl libc, and has minimal pre-installed packages. Distroless images (from Google) go further: no shell, no package manager, only the runtime needed for your language.

Multi-Stage Builds

Build tools should never be in your production image:

# Build stage
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Production stage - only JRE, no Maven
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/target/app.jar /app/app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

The production image contains only what’s needed to run: the JRE and your application JAR. No Maven, no source code, no build tools.

Pin Base Image Tags

Never use mutable tags:

# Bad - who knows what this is today
FROM ubuntu:latest

# Bad - still mutable
FROM nginx:1.25

# Good - immutable by digest (get the real digest with: docker manifest inspect nginx:1.25)
FROM nginx:1.25@sha256:<insert-actual-sha256-digest-here>

Pinning by digest ensures you’re running exactly the image you tested, not whatever 1.25 points to today.

Don’t Store Secrets in Images

Never bake secrets into images. No .env files, no hardcoded credentials:

# Never do this
ENV API_KEY=my-secret-api-key
COPY .env /app/.env

Secrets belong in Kubernetes Secrets (properly managed with SOPS or External Secrets), injected at runtime.

Layer 2: Kubernetes Security Context

Even with a secure image, the Kubernetes securityContext determines what privileges the container actually has at runtime.

Pod Security Context

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: my-app
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop:
            - ALL

Breaking down what each field does:

runAsNonRoot: true: Kubernetes admission controller refuses to run the container if it would run as root. Belt-and-suspenders over the Dockerfile USER directive.

allowPrivilegeEscalation: false: Prevents the setuid bit on executables from being used to gain higher privileges. Critical for preventing privilege escalation inside the container.

readOnlyRootFilesystem: true: The container’s root filesystem is mounted read-only. Malware can’t write new executables to the filesystem. Applications that need to write use explicitly mounted volumes:

volumeMounts:
  - name: tmp
    mountPath: /tmp
  - name: cache
    mountPath: /app/cache
volumes:
  - name: tmp
    emptyDir: {}
  - name: cache
    emptyDir: {}

capabilities: drop: [ALL]: Linux capabilities are fine-grained privileges. Dropping ALL removes every privilege the container would have by default. Only add back what’s explicitly needed. Most applications need no capabilities at all.

seccompProfile: RuntimeDefault: Restricts the system calls available to the container using the container runtime’s default seccomp profile. This significantly reduces the syscall attack surface.

Layer 3: Pod Security Standards

Kubernetes 1.25+ ships with Pod Security Standards (PSS), replacing the deprecated PodSecurityPolicy. Three levels:

Privileged: No restrictions. Don’t use in production.

Baseline: Prevents the most obvious escalations. Allows running as non-root.

Restricted: Heavily restricted. Requires running as non-root, dropping capabilities, read-only root filesystem.

Enforce PSS per namespace:

apiVersion: v1
kind: Namespace
metadata:
  name: my-app
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

Applications must meet the “restricted” profile to deploy in this namespace. This is enforced by the Kubernetes API server before the pod is scheduled.

Layer 4: Image Signing and Verification

Container supply chain attacks (like the SolarWinds incident but for container images) are a real threat. An attacker who compromises your CI system can push malicious images to your registry. Image signing creates a cryptographic chain of trust:

# Sign an image with cosign
cosign sign --key cosign.key myregistry.example.com/myapp:1.4.2

# Verify before deploying
cosign verify --key cosign.pub myregistry.example.com/myapp:1.4.2

Kyverno can enforce that only signed images run in your cluster:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: enforce
  rules:
    - name: check-image-signature
      match:
        resources:
          kinds:
            - Pod
      verifyImages:
        - imageReferences:
            - "myregistry.example.com/myapp:*"
          attestors:
            - entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      <your cosign public key>
                      -----END PUBLIC KEY-----

If someone tries to deploy an unsigned or tampered image from your registry, Kyverno blocks it at admission.

Layer 5: Runtime Security with Falco

Even with all the above, containers can be compromised. Runtime security detects malicious behavior in running containers:

Falco is a CNCF project that monitors system calls and raises alerts on suspicious behavior:

# Custom Falco rule
- rule: Shell Spawned in Container
  desc: Detect shell spawned in a running container
  condition: spawned_process and container and shell_procs and proc.pname = "java"
  output: >
    Shell spawned in java container
    (user=%user.name container=%container.name
    image=%container.image.repository command=%proc.cmdline)
  priority: WARNING

Common detections: unexpected outbound connections, new binary execution, privileged container operations, file read on sensitive paths (/etc/shadow, /etc/kubernetes/admin.conf).

The Security Checklist

For every containerized application:

  • Non-root user in Dockerfile
  • Minimal base image (Alpine or distroless)
  • Multi-stage build (build tools not in production image)
  • Base image pinned by digest
  • No secrets in image
  • allowPrivilegeEscalation: false
  • readOnlyRootFilesystem: true
  • capabilities: drop: [ALL]
  • runAsNonRoot: true
  • seccompProfile: RuntimeDefault
  • Namespace enforcing restricted PSS
  • Image signed and signature verification enforced
  • Image scanned for CVEs (Trivy or similar)
  • Falco or equivalent runtime security deployed

None of these individually is sufficient. Together, they create defense in depth that requires attackers to chain multiple exploits to achieve meaningful impact. That’s the goal.

Share this post: LinkedIn Reddit WhatsApp Mastodon
Jesse Borden

Jesse Borden

Software Engineer with an interest in hands on learning

I have several years of professional Information Technology (IT) experience leading staff and projects within the Department of War (DOW). I have managed Service Desk, Web Application Development, and System Administration teams. My two greatest passions are learning and conti...