Container Security
Overview
This skill covers security best practices for containerized applications, including Docker image hardening, Kubernetes security configurations, image vulnerability scanning, and runtime protection.
Keywords: container security, Docker, Kubernetes, image scanning, Dockerfile, pod security, network policies, RBAC, container runtime, Trivy, Falco, gVisor, seccomp, AppArmor, distroless, rootless containers
When to Use This Skill
-
Building secure Docker images
-
Configuring Kubernetes pod security
-
Setting up container vulnerability scanning
-
Implementing Kubernetes RBAC
-
Configuring network policies
-
Managing secrets in Kubernetes
-
Setting up runtime security monitoring
Container Security Layers
Layer Controls Tools
Image Minimal base, vulnerability scanning, signing Trivy, Cosign, Grype
Build Multi-stage builds, non-root, no secrets Docker, Buildah, Kaniko
Registry Scanning, signing verification, access control Harbor, ECR, ACR
Runtime Seccomp, AppArmor, read-only root gVisor, Kata, Falco
Orchestration Pod security, RBAC, network policies Kubernetes, OPA/Gatekeeper
Secrets Encrypted at rest, external providers Vault, Sealed Secrets, ESO
Secure Dockerfile Patterns
Minimal Secure Dockerfile
Use specific version, not :latest
FROM node:20.10-alpine3.19 AS builder
Create non-root user early
RUN addgroup -g 1001 -S appgroup &&
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
Copy dependency files first (layer caching)
COPY package*.json ./
Install dependencies with security flags
RUN npm ci --only=production --ignore-scripts &&
npm cache clean --force
Copy application code
COPY --chown=appuser:appgroup . .
Build if needed
RUN npm run build
--- Production Stage ---
FROM node:20.10-alpine3.19 AS production
Security: Don't run as root
RUN addgroup -g 1001 -S appgroup &&
adduser -u 1001 -S appuser -G appgroup
Security: Remove unnecessary packages
RUN apk --no-cache add dumb-init &&
rm -rf /var/cache/apk/*
WORKDIR /app
Copy only production artifacts
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules COPY --from=builder --chown=appuser:appgroup /app/package.json ./
Security: Read-only filesystem support
RUN mkdir -p /app/tmp && chown appuser:appgroup /app/tmp
Switch to non-root user
USER appuser
Security: Use dumb-init to handle signals
ENTRYPOINT ["dumb-init", "--"]
Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3
CMD node healthcheck.js || exit 1
Expose port (non-privileged)
EXPOSE 3000
CMD ["node", "dist/server.js"]
Distroless Production Image
Build stage with full toolchain
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app COPY go.mod go.sum ./ RUN go mod download
COPY . .
Build static binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build -ldflags='-w -s -extldflags "-static"'
-o /app/server ./cmd/server
--- Distroless production image ---
FROM gcr.io/distroless/static-debian12:nonroot
Copy binary and CA certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /app/server /server
Run as non-root (65532 is the nonroot user in distroless)
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
Image Scanning
Trivy Scanning
Scan image for vulnerabilities
trivy image --severity CRITICAL,HIGH myapp:latest
Scan with SBOM generation
trivy image --format cyclonedx --output sbom.json myapp:latest
Scan filesystem (for CI before building)
trivy fs --security-checks vuln,secret,config .
Scan with exit code for CI
trivy image --exit-code 1 --severity CRITICAL myapp:latest
Ignore unfixed vulnerabilities
trivy image --ignore-unfixed myapp:latest
CI Pipeline Image Scanning
.github/workflows/container-security.yml
name: Container Security
on: push: branches: [main] pull_request: branches: [main]
jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: '1'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
- name: Run Dockle linter
uses: erzz/dockle-action@v1
with:
image: myapp:${{ github.sha }}
failure-threshold: high
- name: Sign image with Cosign
if: github.ref == 'refs/heads/main'
env:
COSIGN_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
run: |
cosign sign --key env://COSIGN_KEY myapp:${{ github.sha }}
Kubernetes Pod Security
Pod Security Standards (PSS)
Enforce Pod Security Standards at namespace level
apiVersion: v1 kind: Namespace metadata: name: production labels: # Enforce restricted policy pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/enforce-version: latest # Warn on baseline violations pod-security.kubernetes.io/warn: baseline pod-security.kubernetes.io/warn-version: latest # Audit all violations pod-security.kubernetes.io/audit: restricted pod-security.kubernetes.io/audit-version: latest
Secure Pod Specification
apiVersion: v1 kind: Pod metadata: name: secure-app labels: app: secure-app spec:
Prevent privilege escalation across containers
securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 seccompProfile: type: RuntimeDefault
Service account with minimal permissions
serviceAccountName: app-minimal-sa automountServiceAccountToken: false
containers: - name: app image: myapp:v1.0.0@sha256:abc123... imagePullPolicy: Always
# Container-level security context
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
# Resource limits (prevent DoS)
resources:
limits:
cpu: "500m"
memory: "256Mi"
ephemeral-storage: "100Mi"
requests:
cpu: "100m"
memory: "128Mi"
# Writable directories via emptyDir
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/cache
# Health probes
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
volumes: - name: tmp emptyDir: sizeLimit: 10Mi - name: cache emptyDir: sizeLimit: 50Mi
DNS policy for security
dnsPolicy: ClusterFirst
Host settings (all disabled for security)
hostNetwork: false hostPID: false hostIPC: false
Network Policies
Default Deny All
Default deny all ingress and egress
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-all namespace: production spec: podSelector: {} policyTypes: - Ingress - Egress
Application-Specific Policy
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: api-network-policy namespace: production spec: podSelector: matchLabels: app: api policyTypes: - Ingress - Egress
ingress: # Allow from ingress controller only - from: - namespaceSelector: matchLabels: name: ingress-nginx podSelector: matchLabels: app.kubernetes.io/name: ingress-nginx ports: - protocol: TCP port: 8080
# Allow from specific services
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
egress: # Allow DNS - to: - namespaceSelector: {} podSelector: matchLabels: k8s-app: kube-dns ports: - protocol: UDP port: 53
# Allow database access
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
# Allow external HTTPS
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 443
Kubernetes RBAC
Minimal Service Account
Service account with no auto-mounted token
apiVersion: v1 kind: ServiceAccount metadata: name: app-minimal-sa namespace: production automountServiceAccountToken: false
Role with minimal permissions
apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: app-role namespace: production rules:
Only allow reading configmaps
- apiGroups: [""] resources: ["configmaps"] resourceNames: ["app-config"] verbs: ["get"]
Only allow reading specific secrets
- apiGroups: [""] resources: ["secrets"] resourceNames: ["app-credentials"] verbs: ["get"]
Bind role to service account
apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: app-role-binding namespace: production subjects:
- kind: ServiceAccount name: app-minimal-sa namespace: production roleRef: kind: Role name: app-role apiGroup: rbac.authorization.k8s.io
Audit RBAC Permissions
List all cluster-admin bindings (high risk)
kubectl get clusterrolebindings -o json | jq '.items[] | select(.roleRef.name == "cluster-admin") | {name: .metadata.name, subjects: .subjects}'
Check service account permissions
kubectl auth can-i --list --as=system:serviceaccount:production:app-minimal-sa
Find overly permissive roles (using wildcards)
kubectl get roles,clusterroles -A -o json | jq '.items[] | select(.rules[]?.resources[]? == "" or .rules[]?.verbs[]? == "") | {name: .metadata.name, namespace: .metadata.namespace}'
Secrets Management
External Secrets Operator
SecretStore pointing to HashiCorp Vault
apiVersion: external-secrets.io/v1beta1 kind: SecretStore metadata: name: vault-backend namespace: production spec: provider: vault: server: "https://vault.example.com" path: "secret" version: "v2" auth: kubernetes: mountPath: "kubernetes" role: "production-role" serviceAccountRef: name: "vault-auth-sa"
External Secret syncing from Vault
apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: name: app-secrets namespace: production spec: refreshInterval: 1h secretStoreRef: name: vault-backend kind: SecretStore target: name: app-secrets creationPolicy: Owner template: type: Opaque data: DATABASE_URL: "{{ .database_url }}" API_KEY: "{{ .api_key }}" data: - secretKey: database_url remoteRef: key: production/app property: database_url - secretKey: api_key remoteRef: key: production/app property: api_key
Sealed Secrets
Install sealed-secrets controller
helm install sealed-secrets sealed-secrets/sealed-secrets
--namespace kube-system
Seal a secret
kubectl create secret generic my-secret
--from-literal=password=supersecret
--dry-run=client -o yaml |
kubeseal --format yaml > sealed-secret.yaml
The sealed secret can be safely committed to git
cat sealed-secret.yaml
Runtime Security
Falco Rules
Custom Falco rules
-
rule: Unauthorized Process in Container desc: Detect unauthorized processes running in containers condition: > spawned_process and container and not proc.name in (allowed_processes) and not container.image.repository in (trusted_images) output: > Unauthorized process started (user=%user.name command=%proc.cmdline container=%container.name image=%container.image.repository) priority: WARNING tags: [container, process]
-
rule: Write to Sensitive Directories desc: Detect writes to sensitive directories in containers condition: > open_write and container and (fd.name startswith /etc/ or fd.name startswith /bin/ or fd.name startswith /sbin/ or fd.name startswith /usr/bin/) output: > Write to sensitive directory (file=%fd.name user=%user.name container=%container.name image=%container.image.repository) priority: ERROR tags: [container, filesystem]
-
rule: Container Shell Spawned desc: Detect shell spawned in container condition: > spawned_process and container and proc.name in (shell_binaries) and not proc.pname in (allowed_shell_parents) output: > Shell spawned in container (user=%user.name shell=%proc.name parent=%proc.pname container=%container.name) priority: WARNING tags: [container, shell]
-
list: shell_binaries items: [bash, sh, zsh, ash, dash, ksh, tcsh, csh]
-
list: allowed_shell_parents items: [crond, sshd, sudo]
Quick Reference
Dockerfile Security Checklist
Check Command/Pattern
No latest tag FROM image:specific-version
Non-root user USER 1000
No secrets in image trivy fs --security-checks secret .
Multi-stage build Separate builder and production stages
Read-only filesystem --read-only or readOnlyRootFilesystem: true
Minimal base image Alpine, distroless, or scratch
Signed image cosign sign / cosign verify
Kubernetes Security Checklist
Check Setting
Non-root runAsNonRoot: true
No privilege escalation allowPrivilegeEscalation: false
Drop capabilities capabilities: {drop: [ALL]}
Read-only root readOnlyRootFilesystem: true
Resource limits resources.limits defined
Network policies Default deny + explicit allow
Seccomp profile seccompProfile: {type: RuntimeDefault}
No host namespaces hostNetwork/PID/IPC: false
References
-
Dockerfile Hardening: See references/dockerfile-security.md for detailed patterns
-
Kubernetes Security: See references/kubernetes-security.md for comprehensive K8s guidance
-
Container Scanning: See references/container-scanning.md for scanner configurations
Last Updated: 2025-12-26