20 minute read Platform Engineering, Software Engineering

Containers have fundamentally changed how we build and deploy applications, but they’ve also introduced new security considerations that many teams struggle to address comprehensively. The speed and convenience of containers can lull organisations into a false sense of security, treating them as lightweight virtual machines without understanding the fundamentally different security model they represent.

The reality is that containers share the host kernel with every other container on the same machine. A vulnerability in that kernel affects every container. A container escape gives an attacker access to the entire host and potentially every other workload running there. Understanding these risks—and the mechanisms Linux provides to mitigate them—is essential for anyone running containers in production.

In this comprehensive guide, I’ll walk you through the fundamental security concepts that underpin container isolation, the practical measures you can implement to harden your containerised applications, and the tools and workflows that make security a natural part of your development process rather than an afterthought bolted on at deployment time.

Understanding Container Isolation

Before we can secure containers effectively, we need to understand how they achieve isolation in the first place. Containers aren’t virtual machines—they don’t run separate kernels or emulate hardware. Instead, they use Linux kernel features to create isolated environments that share the same kernel whilst appearing to be separate systems.

The Shared Kernel Model

Every container on a host shares the same Linux kernel. When you run docker run ubuntu:22.04, you’re not running an Ubuntu kernel—you’re running the host’s kernel with an Ubuntu userspace. This is fundamentally different from virtual machines, where each VM runs its own kernel instance.

This shared kernel model brings both efficiency and risk. Containers start in milliseconds because there’s no kernel to boot. They use less memory because there’s no kernel overhead per container. But a kernel vulnerability affects every container on the host, and a container escape potentially compromises every workload.

Understanding this model shapes how we think about container security. We’re not securing isolated systems; we’re securing processes that share resources with other processes whilst maintaining the illusion of isolation.

Linux Namespaces

Namespaces are the primary isolation mechanism for containers. They partition kernel resources so that one set of processes sees one set of resources whilst another set of processes sees a different set. Linux provides several namespace types, each isolating a different aspect of the system.

The PID namespace isolates process IDs. Inside a container, processes see themselves as PID 1, 2, 3, and so on, but from the host’s perspective, these same processes have entirely different PIDs. This prevents containers from seeing or signalling processes in other containers.

The network namespace isolates network interfaces, routing tables, and firewall rules. Each container gets its own network stack, appearing to have dedicated network interfaces even though the physical hardware is shared.

The mount namespace isolates filesystem mount points. A container sees its own root filesystem without access to the host’s filesystem or other containers’ filesystems.

The UTS namespace isolates hostname and domain name. Each container can have its own hostname without affecting others.

The IPC namespace isolates inter-process communication resources like shared memory segments and message queues. This prevents containers from communicating through these mechanisms unless explicitly allowed.

The user namespace maps user and group IDs inside the container to different IDs outside. Root inside the container can be mapped to an unprivileged user on the host, significantly reducing the impact of container escapes.

Here’s how you can inspect namespaces for a running container:

# Get the container's PID on the host
CONTAINER_PID=$(docker inspect --format '' my-container)

# List the namespaces for this process
ls -la /proc/$CONTAINER_PID/ns/

# Compare with host namespaces
ls -la /proc/1/ns/

Control Groups (cgroups)

While namespaces provide isolation of what a container can see, cgroups control what resources a container can use. They limit and account for CPU, memory, disk I/O, and network bandwidth consumption.

Without cgroups, a single container could consume all available memory, causing the kernel’s out-of-memory killer to terminate other containers or even critical host processes. A container could monopolise CPU, starving other workloads. Cgroups prevent this resource starvation.

# View cgroup limits for a container
docker inspect --format '' my-container
docker inspect --format '' my-container

# Set resource limits when running a container
docker run -d \
  --memory="512m" \
  --memory-swap="512m" \
  --cpus="1.5" \
  --pids-limit=100 \
  myapp:latest

The --memory flag limits total memory. The --memory-swap flag set equal to --memory disables swap, preventing the container from using swap space when memory is exhausted. The --cpus flag limits CPU usage to 1.5 cores. The --pids-limit flag prevents fork bombs by limiting the number of processes.

Linux Capabilities

Traditional Unix security divides the world into two categories: root, which can do anything, and everyone else, which is heavily restricted. This is too coarse-grained for containers. You might need a container that can bind to privileged ports but can’t load kernel modules.

Linux capabilities split root’s powers into distinct units that can be granted independently. Instead of running as root with all powers, you can run as root with only the specific capabilities needed.

# Run a container with minimal capabilities
docker run -d \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  myapp:latest

# List capabilities of a running container
docker inspect --format '' my-container
docker inspect --format '' my-container

Key capabilities to understand:

  • CAP_NET_BIND_SERVICE: Bind to ports below 1024
  • CAP_NET_RAW: Use raw sockets (needed for ping)
  • CAP_SYS_ADMIN: A catch-all for various admin operations (avoid granting this)
  • CAP_SYS_PTRACE: Trace processes (needed for debugging)
  • CAP_CHOWN: Change file ownership
  • CAP_SETUID / CAP_SETGID: Change user/group IDs

The principle of least privilege demands dropping all capabilities and adding back only those specifically required. Most applications need far fewer capabilities than Docker grants by default.

Seccomp Profiles

Seccomp (secure computing mode) filters system calls that a process can make. The Linux kernel provides hundreds of syscalls, but most applications need only a fraction of them. Seccomp profiles whitelist or blacklist specific syscalls, reducing the kernel attack surface available to a container.

Docker applies a default seccomp profile that blocks around 44 syscalls considered dangerous, including reboot, mount, and kexec_load. You can create custom profiles for tighter restrictions:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 1,
  "architectures": [
    "SCMP_ARCH_X86_64",
    "SCMP_ARCH_AARCH64"
  ],
  "syscalls": [
    {
      "names": [
        "accept",
        "accept4",
        "bind",
        "clone",
        "close",
        "connect",
        "dup",
        "dup2",
        "execve",
        "exit",
        "exit_group",
        "fcntl",
        "fstat",
        "futex",
        "getpid",
        "getsockname",
        "getsockopt",
        "listen",
        "mmap",
        "mprotect",
        "munmap",
        "open",
        "openat",
        "read",
        "recvfrom",
        "rt_sigaction",
        "rt_sigprocmask",
        "sendto",
        "setsockopt",
        "socket",
        "write"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Apply a custom seccomp profile:

docker run -d \
  --security-opt seccomp=/path/to/profile.json \
  myapp:latest

To generate a seccomp profile for your application, you can use tools like strace to observe which syscalls your application actually makes, then create a whitelist profile.

Image Security

Container images are the foundation of your containerised applications. A vulnerable or malicious base image compromises everything built upon it. Securing your images starts with choosing the right base and extends through scanning, signing, and supply chain verification.

Choosing Secure Base Images

The base image you choose dramatically affects your security posture. A full Ubuntu image contains thousands of packages, each potentially harbouring vulnerabilities. A minimal image contains only what’s necessary, reducing attack surface significantly.

Distroless Images

Google’s distroless images contain only your application and its runtime dependencies—no shell, no package manager, no unnecessary utilities. An attacker who gains code execution can’t spawn a shell because there isn’t one.

# Build stage
FROM golang:1.21 AS builder

WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

# Runtime stage - distroless
FROM gcr.io/distroless/static-debian12

COPY --from=builder /app/myapp /
USER nonroot:nonroot

ENTRYPOINT ["/myapp"]

Alpine Linux

Alpine uses musl libc instead of glibc and provides a minimal base around 5MB. It includes a package manager but far fewer default packages than traditional distributions.

FROM alpine:3.19

RUN apk add --no-cache ca-certificates tzdata

COPY myapp /usr/local/bin/
USER nobody:nobody

ENTRYPOINT ["/usr/local/bin/myapp"]

Scratch Images

The scratch image is literally empty; no files, no filesystem, nothing. It’s suitable for statically compiled binaries that include everything they need.

FROM golang:1.21 AS builder

WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o myapp

FROM scratch

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp /

ENTRYPOINT ["/myapp"]

Vulnerability Scanning

Vulnerability scanners analyse your images against databases of known vulnerabilities (CVEs). They identify vulnerable packages and provide severity ratings to help prioritise remediation.

Trivy

Trivy is a comprehensive, fast scanner that checks for OS package vulnerabilities, language-specific dependencies, misconfigurations, and secrets.

# Scan an image
trivy image myapp:latest

# Scan with specific severity threshold
trivy image --severity HIGH,CRITICAL myapp:latest

# Scan and fail if vulnerabilities found (useful for CI)
trivy image --exit-code 1 --severity CRITICAL myapp:latest

# Scan a Dockerfile for misconfigurations
trivy config Dockerfile

# Generate SBOM while scanning
trivy image --format spdx-json --output sbom.spdx.json myapp:latest

Grype

Grype from Anchore focuses specifically on vulnerability scanning with excellent accuracy and speed.

# Scan an image
grype myapp:latest

# Output as JSON for processing
grype -o json myapp:latest > vulnerabilities.json

# Fail on specific severity
grype myapp:latest --fail-on critical

Snyk

Snyk provides vulnerability scanning with remediation advice and integrates well with development workflows.

# Scan an image
snyk container test myapp:latest

# Monitor for new vulnerabilities
snyk container monitor myapp:latest

# Scan and provide fix recommendations
snyk container test myapp:latest --file=Dockerfile

Multi-Stage Builds for Security

Multi-stage builds are a security feature as much as an optimisation. They ensure build tools, source code, test data, and secrets used during build don’t end up in your final image.

# Stage 1: Dependencies
FROM node:20-alpine AS dependencies

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Build
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build
RUN npm run test

# Stage 3: Production - only runtime artifacts
FROM node:20-alpine AS production

RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

WORKDIR /app

# Copy only production dependencies
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules

# Copy only built artifacts
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./

USER nodejs

EXPOSE 3000

CMD ["node", "dist/server.js"]

Notice what doesn’t appear in the final image: npm (we use node directly), the full node_modules with dev dependencies, source TypeScript files, test files, and any secrets used during the build process. The final image contains only what’s needed to run the application.

Image Signing and Verification

Image signing cryptographically proves that an image came from a trusted source and hasn’t been tampered with. Sigstore’s Cosign has become the de facto standard for container image signing.

# Generate a key pair (for traditional key-based signing)
cosign generate-key-pair

# Sign an image with a key
cosign sign --key cosign.key myregistry/myapp:v1.0.0

# Sign using keyless signing (recommended)
# This uses OIDC identity from your CI provider
cosign sign myregistry/myapp:v1.0.0

# Verify a signature
cosign verify --key cosign.pub myregistry/myapp:v1.0.0

# Verify keyless signature
cosign verify \
  --certificate-identity=https://github.com/myorg/myapp/.github/workflows/build.yml@refs/tags/v1.0.0 \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  myregistry/myapp:v1.0.0

Keyless signing with Sigstore is particularly powerful for CI/CD. It uses your CI provider’s OIDC identity to sign images, eliminating the need to manage signing keys. The signature includes claims about who built the image and in what context, providing strong provenance guarantees.

Software Bill of Materials (SBOM)

An SBOM lists all components in your software, including libraries, dependencies, and their versions. When a new vulnerability is discovered, an SBOM lets you quickly determine which of your images are affected.

# Generate SBOM with Syft
syft myapp:latest -o spdx-json > sbom.spdx.json

# Generate SBOM with Trivy
trivy image --format spdx-json --output sbom.json myapp:latest

# Attach SBOM to image with Cosign
cosign attach sbom --sbom sbom.spdx.json myregistry/myapp:v1.0.0

# Scan SBOM for vulnerabilities
grype sbom:sbom.spdx.json

Store SBOMs alongside your images and keep them for the lifetime of the image. When Log4Shell or the next major vulnerability hits, you’ll be able to query your SBOMs to identify affected images within minutes rather than days.

Runtime Security

Securing images is essential but insufficient. Runtime security focuses on protecting containers while they execute, limiting what they can do even if compromised.

Running as Non-Root

By default, containers run as root. A vulnerability that allows code execution gives the attacker root privileges inside the container. Whilst namespaces and capabilities limit what root can do, defence in depth demands running as an unprivileged user.

FROM node:20-alpine

# Create a non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

WORKDIR /app

# Change ownership of application files
COPY --chown=appuser:appgroup . .

RUN npm ci --only=production

# Switch to non-root user
USER appuser

EXPOSE 3000

CMD ["node", "server.js"]

Some images require specific user IDs to match existing filesystem permissions or to integrate with orchestration platforms. Kubernetes, for example, can enforce that containers run as non-root:

apiVersion: v1
kind: Pod
metadata:
  name: secure-pod
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1001
    runAsGroup: 1001
    fsGroup: 1001
  containers:
    - name: app
      image: myapp:latest
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop:
            - ALL

Read-Only Root Filesystem

A read-only root filesystem prevents attackers from modifying binaries, dropping malware, or tampering with configuration. Any write attempts fail, limiting post-exploitation options.

# Run with read-only filesystem
docker run -d \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /var/run:rw,noexec,nosuid,size=16m \
  myapp:latest

The --tmpfs mounts provide writable temporary directories in memory for applications that need to write temporary files or PID files. The noexec option prevents execution of files in these directories.

Your Dockerfile should prepare for read-only operation:

FROM node:20-alpine

RUN adduser -S appuser

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

# Ensure no writes needed to root filesystem
# Pre-create any directories the app expects to exist
RUN mkdir -p /app/cache && chown appuser /app/cache

USER appuser

CMD ["node", "server.js"]

Resource Limits

Resource limits prevent denial-of-service attacks where a compromised container exhausts host resources. They also protect against accidental resource exhaustion from memory leaks or runaway processes.

docker run -d \
  --memory="256m" \
  --memory-swap="256m" \
  --memory-reservation="128m" \
  --cpus="0.5" \
  --cpu-shares=512 \
  --pids-limit=50 \
  --ulimit nofile=1024:1024 \
  --ulimit nproc=64:64 \
  myapp:latest

In Kubernetes, specify resource requests and limits:

apiVersion: v1
kind: Pod
metadata:
  name: resource-limited-pod
spec:
  containers:
    - name: app
      image: myapp:latest
      resources:
        requests:
          memory: "128Mi"
          cpu: "250m"
        limits:
          memory: "256Mi"
          cpu: "500m"

Network Policies

Network policies control which containers can communicate with each other and with external services. By default, all containers can communicate with all other containers—a compromised container can probe and attack anything on the network.

In Kubernetes, NetworkPolicy resources define allowed traffic:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-network-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
        - namespaceSelector:
            matchLabels:
              name: monitoring
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: database
      ports:
        - protocol: TCP
          port: 5432
    - to:
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53

This policy allows the API pod to receive traffic only from the frontend application and monitoring namespace, and to send traffic only to the database and DNS.

For Docker without Kubernetes, use Docker networks to isolate containers:

# Create isolated networks
docker network create --internal backend-net
docker network create frontend-net

# Database only on internal network (no external access)
docker run -d --network backend-net --name db postgres:15

# API on both networks
docker run -d --network backend-net --network frontend-net --name api myapi:latest

# Frontend only on frontend network
docker run -d --network frontend-net -p 80:80 --name web nginx:alpine

Runtime Threat Detection

Runtime security tools monitor container behaviour and alert on or block suspicious activity. They detect attacks that static scanning can’t catch—exploits against zero-day vulnerabilities, malicious insider activity, or attacks using legitimate tools.

Falco

Falco is an open-source runtime security tool that monitors syscalls and alerts on suspicious behaviour.

# Example Falco rules
- rule: Terminal Shell in Container
  desc: Detect shell spawned in a container
  condition: >
    spawned_process and 
    container and 
    shell_procs and 
    proc.tty != 0
  output: >
    Shell spawned in container 
    (user=%user.name container=%container.name shell=%proc.name 
    parent=%proc.pname cmdline=%proc.cmdline)
  priority: WARNING
  tags: [container, shell, mitre_execution]

- rule: Write Below Binary Dir
  desc: Detect writes to binary directories
  condition: >
    open_write and 
    container and 
    bin_dir
  output: >
    Write to binary directory 
    (user=%user.name container=%container.name file=%fd.name)
  priority: CRITICAL
  tags: [container, filesystem, mitre_persistence]

- rule: Outbound Connection to Unusual Port
  desc: Detect outbound connections to non-standard ports
  condition: >
    outbound and 
    container and 
    not (fd.sport in (80, 443, 53, 8080, 8443, 5432, 6379, 3306))
  output: >
    Unusual outbound connection 
    (container=%container.name connection=%fd.name)
  priority: NOTICE
  tags: [container, network, mitre_exfiltration]

Deploy Falco in Kubernetes:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: falco
  namespace: falco
spec:
  selector:
    matchLabels:
      app: falco
  template:
    metadata:
      labels:
        app: falco
    spec:
      serviceAccountName: falco
      hostNetwork: true
      hostPID: true
      containers:
        - name: falco
          image: falcosecurity/falco:latest
          securityContext:
            privileged: true
          volumeMounts:
            - name: dev
              mountPath: /host/dev
            - name: proc
              mountPath: /host/proc
              readOnly: true
            - name: etc
              mountPath: /host/etc
              readOnly: true
      volumes:
        - name: dev
          hostPath:
            path: /dev
        - name: proc
          hostPath:
            path: /proc
        - name: etc
          hostPath:
            path: /etc

Secret Management

Secrets in containers require careful handling. Environment variables, while convenient, can leak through logs, process listings, or debug endpoints. Build-time secrets must never persist in image layers. Runtime secrets should be injected securely and rotated regularly.

Build-Time Secrets

Docker BuildKit provides secure secret mounting during builds. Secrets are available only during the build step that mounts them and don’t persist in image layers.

# syntax=docker/dockerfile:1.4
FROM node:20-alpine

WORKDIR /app
COPY package*.json ./

# Mount npm token as secret - only available during this RUN
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
    npm ci && \
    rm .npmrc

COPY . .
RUN npm run build

CMD ["node", "dist/server.js"]

Build with secrets:

# Pass secret from environment variable
export NPM_TOKEN="your-token-here"
docker buildx build --secret id=npm_token,env=NPM_TOKEN -t myapp:latest .

# Pass secret from file
echo "your-token-here" > npm_token.txt
docker buildx build --secret id=npm_token,src=npm_token.txt -t myapp:latest .

For SSH access to private repositories:

# syntax=docker/dockerfile:1.4
FROM golang:1.21 AS builder

WORKDIR /app

# Mount SSH agent for private repo access
RUN --mount=type=ssh \
    mkdir -p ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    go mod download

COPY . .
RUN go build -o myapp

FROM gcr.io/distroless/static
COPY --from=builder /app/myapp /
CMD ["/myapp"]

Build with SSH forwarding:

docker buildx build --ssh default -t myapp:latest .

Runtime Secrets

For runtime secrets, avoid environment variables where possible. Use mounted files or integrate with secret management systems.

Docker Secrets (Swarm)

# Create a secret
echo "supersecretpassword" | docker secret create db_password -

# Use in service
docker service create \
  --name api \
  --secret db_password \
  myapp:latest

Inside the container, the secret appears at /run/secrets/db_password.

Kubernetes Secrets

apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  database-password: "supersecretpassword"
  api-key: "sk-1234567890"
---
apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
    - name: app
      image: myapp:latest
      volumeMounts:
        - name: secrets
          mountPath: /etc/secrets
          readOnly: true
  volumes:
    - name: secrets
      secret:
        secretName: app-secrets

External Secret Stores

For production, integrate with external secret stores like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault:

# Using External Secrets Operator with AWS Secrets Manager
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: app-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-secrets-manager
  target:
    name: app-secrets
  data:
    - secretKey: database-password
      remoteRef:
        key: production/database
        property: password
    - secretKey: api-key
      remoteRef:
        key: production/api
        property: key

CI/CD Security Integration

Security must be automated into your CI/CD pipeline. Manual security reviews don’t scale, and security gates that slow down deployment get bypassed. Build security into the pipeline so it happens automatically on every build.

Comprehensive Security Pipeline

Here’s a GitHub Actions workflow that integrates vulnerability scanning, SBOM generation, and image signing:

name: Secure Container Build

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  security-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Run Trivy vulnerability scanner (filesystem)
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          format: 'sarif'
          output: 'trivy-fs-results.sarif'
      
      - name: Upload Trivy scan results to GitHub Security
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: 'trivy-fs-results.sarif'

  build-and-push:
    needs: security-scan
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write  # For keyless signing
      security-events: write
    
    outputs:
      digest: $
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: $/$
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern=
            type=semver,pattern=.
            type=sha
      
      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: $
          tags: $
          labels: $
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true
          sbom: true
      
      - name: Run Trivy vulnerability scanner (image)
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: $/$:$
          format: 'sarif'
          output: 'trivy-image-results.sarif'
          severity: 'CRITICAL,HIGH'
        if: github.event_name != 'pull_request'
      
      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: 'trivy-image-results.sarif'
        if: github.event_name != 'pull_request'

  sign-image:
    needs: build-and-push
    runs-on: ubuntu-latest
    if: github.event_name != 'pull_request'
    permissions:
      contents: read
      packages: write
      id-token: write
    
    steps:
      - name: Install Cosign
        uses: sigstore/cosign-installer@v3
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Sign the image with Cosign
        env:
          DIGEST: $
        run: |
          cosign sign --yes $/$@${DIGEST}

  generate-sbom:
    needs: build-and-push
    runs-on: ubuntu-latest
    if: github.event_name != 'pull_request'
    permissions:
      contents: read
      packages: write
    
    steps:
      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Generate SBOM
        env:
          DIGEST: $
        run: |
          syft $/$@${DIGEST} -o spdx-json > sbom.spdx.json
      
      - name: Upload SBOM as artifact
        uses: actions/upload-artifact@v3
        with:
          name: sbom
          path: sbom.spdx.json

Security Gates

Define security thresholds that must be met before images can be deployed:

# Add to your build job
- name: Check vulnerability threshold
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: $/$:$
    format: 'table'
    exit-code: '1'  # Fail the build
    severity: 'CRITICAL'  # Only fail on critical vulnerabilities
    ignore-unfixed: true  # Ignore vulnerabilities without fixes

- name: Verify image signature before deployment
  run: |
    cosign verify \
      --certificate-identity-regexp="https://github.com/$/*" \
      --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
      $/$@$

Admission Control

In Kubernetes, use admission controllers to enforce security policies. Kyverno and OPA Gatekeeper can verify image signatures, enforce security contexts, and block non-compliant deployments.

# Kyverno policy requiring signed images
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestors:
            - entries:
                - keyless:
                    issuer: "https://token.actions.githubusercontent.com"
                    subject: "https://github.com/myorg/*"
---
# Policy enforcing security contexts
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-security-context
spec:
  validationFailureAction: Enforce
  rules:
    - name: require-non-root
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Containers must run as non-root"
        pattern:
          spec:
            securityContext:
              runAsNonRoot: true
            containers:
              - securityContext:
                  allowPrivilegeEscalation: false
                  capabilities:
                    drop:
                      - ALL

Security Checklist

Here’s a comprehensive checklist for container security. Use it as a starting point and adapt it to your specific requirements:

Image Security

  • Use minimal base images (distroless, Alpine, or scratch)
  • Pin base image versions with digests, not just tags
  • Scan images for vulnerabilities in CI/CD
  • Generate and store SBOMs for all images
  • Sign images with Cosign or similar
  • Use multi-stage builds to exclude build tools
  • Remove unnecessary packages and files
  • Don’t install debugging tools in production images

Runtime Security

  • Run containers as non-root users
  • Drop all capabilities and add only what’s needed
  • Use read-only root filesystems
  • Set resource limits (memory, CPU, PIDs)
  • Apply seccomp profiles
  • Disable privilege escalation
  • Use network policies to restrict communication
  • Deploy runtime threat detection (Falco)

Secret Management

  • Never commit secrets to version control
  • Use BuildKit secrets for build-time credentials
  • Mount secrets as files, not environment variables
  • Integrate with external secret stores
  • Rotate secrets regularly
  • Audit secret access

CI/CD Security

  • Scan code and dependencies before building
  • Scan images after building
  • Sign images in CI/CD pipeline
  • Verify signatures before deployment
  • Use admission controllers to enforce policies
  • Implement security gates with severity thresholds

Infrastructure Security

  • Keep container runtime updated
  • Keep orchestration platform updated
  • Use private registries with authentication
  • Enable audit logging
  • Monitor and alert on security events
  • Regularly review and update security policies

Conclusion

Container security isn’t a single tool or practice—it’s a comprehensive approach that spans the entire lifecycle from base image selection through runtime monitoring. The shared kernel model means that container isolation, while effective, is not equivalent to the stronger isolation of virtual machines. Defence in depth is essential.

Start with the fundamentals: understand namespaces, cgroups, and capabilities. Choose minimal base images that reduce attack surface. Scan for vulnerabilities and generate SBOMs so you can respond quickly when new CVEs emerge. Run as non-root with minimal capabilities. Apply network policies to limit blast radius.

Automate security into your CI/CD pipeline so it happens consistently on every build. Sign your images to prove provenance. Use admission controllers to enforce policies at deployment time. Deploy runtime security tools to detect attacks that static analysis can’t catch.

Container security is an ongoing practice, not a one-time implementation. Vulnerabilities are discovered daily. Attackers evolve their techniques. Your security posture must evolve with them. Regular audits, updated policies, and continuous monitoring ensure your containerised applications remain protected as the threat landscape changes.

The investment in container security pays dividends in reduced risk, faster incident response, and confidence that your applications are protected by multiple layers of defence. You’ve built a foundation for secure containerised applications that will serve you well as your deployment grows.

Leave a comment