Container Security Fundamentals: Protecting Your Containerised Applications
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 1024CAP_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 ownershipCAP_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