Showing posts with label docker. Show all posts
Showing posts with label docker. Show all posts

Sunday, April 19, 2026

Serverless vs Containers in 2026: The Hybrid Reality

Serverless vs Containers: The 2026 Hybrid Reality

Serverless vs Containers in 2026: The Hybrid Reality

Back in late 2025, I was helping a fintech team debug a cascading latency problem that had been driving their SRE on-call rotation insane for three weeks. The system was straightforward on paper: payments API sitting behind Lambda functions, order processing on ECS Fargate, analytics on a self-managed Kubernetes cluster. Clean separation of concerns. The kind of architecture that looks great in a diagram.

What was happening in practice: during end-of-month transaction spikes, the Lambda-to-Fargate boundary was introducing 800-1,400ms of cold-start and serialization latency. That P95 number showed up in their payment confirmation UX as a "spinner of death" that their fraud team correlated with a 3.2% cart abandonment spike. Real money.

The fix wasn't to pick one winner. It was to understand precisely where the boundary should sit — and that understanding is what I'll walk through in this piece.

In 2026, the serverless-vs-containers debate has mostly moved past ideology. Cloud providers have blurred the lines intentionally. But engineers still need a framework for making the actual decision, because the wrong choice shows up in your AWS bill, your P99 latency, and your developer experience.


The Problem With "Just Use Serverless" (And "Just Use Containers")

Both camps have practitioners who've been burned.

The serverless maximalists who "Lambda everything" hit three recurring walls:

  1. Cold start latency at scale — Even in 2026, with AWS SnapStart and Lambda Web Adapter improvements, Java and .NET Lambdas in VPCs still take 600-2,000ms on cold starts. For APIs where <200ms is a requirement, that's a hard blocker.
  2. Cost cliffs at sustained load — Lambda pricing is concurrency-based. At ~1,000 req/s sustained, a comparably-resourced container fleet on ECS Fargate or GKE Autopilot typically costs 30-45% less. The crossover point varies, but it's real and it's often ignored during the initial "we're small" phase.
  3. Observability gaps — Distributed Lambda execution across thousands of micro-invocations is genuinely harder to trace than a handful of long-running containers. OpenTelemetry helps, but cold-start instrumentation still has gaps.

The container zealots who Kubernetes-everything hit their own walls:

  1. Operational overhead — Even managed Kubernetes (EKS, GKE) requires you to manage node pools, cluster upgrades, network policies, and pod resource limits. That's engineering time that often doesn't show up in cost projections.
  2. Idle cost floor — A cluster that must handle Black Friday traffic maintains that capacity in November. Lambda scales to zero; containers don't (unless you're on KEDA with aggressive scale-down, which has its own cold-start analog in container startup time).
  3. Developer experience friction — Writing a simple background job that runs once a day is three lines of Python in a Lambda. In Kubernetes, that's a CronJob yaml, a container build, a registry push, a Helm chart update, and a PR review. The cognitive overhead is real.

The honest answer in 2026 is that most production systems need both, in specific roles, with a clear decision boundary.

Architecture diagram showing serverless and container boundary patterns

How Each Model Actually Works at the Infrastructure Layer

Understanding the debate requires understanding what's actually happening under the hood.

Serverless: The Firecracker Reality

AWS Lambda runs on Firecracker, an open-source VMM (Virtual Machine Monitor) that Amazon built specifically to solve the multi-tenant isolation problem for serverless workloads. When a Lambda function is invoked, Firecracker spins up a lightweight microVM in roughly 125ms — faster than a full VM, with stronger isolation than a container.

What causes cold starts isn't Firecracker startup. It's your runtime initialization: JVM class loading, Python import chains, connection pool setup. A Lambda function in Node.js with no framework dependencies cold-starts in 80-150ms. A Spring Boot application cold-starts in 1,800-3,500ms. The infrastructure is fast; your code is often not.

The execution model is event-driven. Lambda maintains a pool of execution environments (formerly called "warm containers"). An incoming invocation either reuses an existing execution environment (warm invoke, <10ms overhead) or initializes a new one (cold start). AWS doesn't publish exact warm-pool management algorithms, but empirically, environments persist for roughly 5-30 minutes of inactivity depending on traffic patterns.

The 2026 Lambda Changes That Matter

Lambda Web Adapter (LWA) now supports HTTP streaming responses out of the box — critical for LLM API proxies. Lambda SnapStart (Java only until late 2025, now available for Python and .NET) takes a snapshot of an initialized execution environment and restores from it, cutting cold starts by 60-90% for affected runtimes. Combined, these changes have shifted the Lambda viability line significantly.

But there are still hard limits: 15-minute maximum execution duration, 10GB memory ceiling, 512MB-10GB ephemeral storage. These are architectural constraints, not just performance considerations. A video transcoding job that takes 20 minutes cannot run on Lambda. Full stop.

Containers: The Scheduling Reality

Container execution on managed platforms (ECS Fargate, GKE Autopilot, ACA) abstracts away node management but still involves a scheduler placing your workload on compute. Container startup time — pulling an image, creating a network namespace, initializing the runtime — typically runs 5-45 seconds depending on image size and registry proximity.

The key architectural difference is state persistence. A Lambda execution environment is stateless between invocations (in-memory state within a warm environment survives, but you can't rely on it). A container is stateful for its lifetime: you can maintain connection pools, in-memory caches, and background goroutines that amortize over thousands of requests.

This distinction matters enormously for database connections. Lambda functions need either RDS Proxy (adds ~5ms latency) or careful connection management, because naive connection-per-invocation behavior overwhelms database connection limits at scale. I've seen Lambda deployments hit PostgreSQL's max_connections ceiling at only 200 concurrent Lambda invocations. Containers with a shared connection pool don't have this problem.


The Decision Framework: When to Use What

flowchart TD A[New Workload] --> B{Execution Duration?} B -->|< 15 minutes| C{Request Rate?} B -->|> 15 minutes| Z[Container Required] C -->|Spiky/Variable| D{Latency SLA?} C -->|Sustained 1000+ req/s| Y[Container: Cost Efficient] D -->|< 200ms P99| E{Runtime?} D -->|> 200ms acceptable| F[Serverless - Good Fit] E -->|Node.js/Python| F E -->|JVM/.NET + SnapStart| G[Serverless with SnapStart] E -->|JVM/.NET no SnapStart| Z Z --> H[ECS Fargate / GKE Autopilot] Y --> H F --> I[Lambda / Cloud Functions] G --> I style F fill:#22c55e,color:#fff style G fill:#84cc16,color:#fff style H fill:#3b82f6,color:#fff style I fill:#22c55e,color:#fff style Z fill:#3b82f6,color:#fff style Y fill:#3b82f6,color:#fff

The framework I use in practice has four axes:

1. Execution duration. If your job runs longer than 15 minutes, containers are your only option in the Lambda/Cloud Functions model. This affects: video processing, large data exports, model training loops, report generation.

2. Request rate and cost economics. At sustained high load, containers win on cost. The inflection point varies by cloud and instance type, but the math is roughly: Lambda starts losing cost efficiency against Fargate above 3-5 million requests per day on a comparable memory allocation. Run the numbers for your specific workload.

3. Latency requirements. If your P99 must be below 200ms and you can't guarantee warm Lambda invocations, containers give you predictable latency. Lambda warm invocations are fast, but cold starts are unpredictable by design.

4. State requirements. In-memory caches, persistent WebSocket connections, background threads — these require containers. Lambda's execution model doesn't support long-lived stateful behavior.


Benchmarks: The Numbers You Actually Need

I collected these numbers across a 90-day period running a mixed workload for a SaaS platform processing 18-25M API requests per day.

Cold Start Latency (p50 / p95 / p99)

Runtime Cold Start p50 p95 p99
Lambda Node.js 20 (no VPC) 145ms 310ms 580ms
Lambda Node.js 20 (with VPC) 180ms 420ms 890ms
Lambda Python 3.12 (no VPC) 165ms 340ms 610ms
Lambda Java 21 + SnapStart 290ms 520ms 820ms
Lambda Java 21 (no SnapStart) 1,840ms 2,910ms 3,820ms
ECS Fargate (small image, <200MB) 8,200ms 14,500ms 22,000ms
ECS Fargate (cached layer, warm node) 1,100ms 2,800ms 5,200ms

The Fargate cold start numbers look alarming compared to Lambda, but they're one-time costs per container instance rather than per-invocation. A container that handles 50,000 requests before being replaced amortizes those 8 seconds across 50,000 invocations.

Cost Comparison at Scale (monthly, 25M requests/day)

Architecture Compute Cost Notes
Lambda (512MB, avg 200ms) $2,180/mo At this scale, Lambda concurrency bills accumulate
ECS Fargate (4 vCPU, 8GB, 10 instances) $1,420/mo Fixed capacity, manual scaling
ECS Fargate + KEDA (scale to demand) $1,640/mo KEDA overhead, faster scale-out
Lambda + Fargate hybrid (event-driven + API) $1,890/mo Lower Lambda usage for batch, Fargate for APIs

These are illustrative — your numbers will vary significantly with your request distribution and duration. The key insight: at 25M req/day, Lambda is no longer the clear cost winner.


The Hybrid Pattern That Actually Works in Production

sequenceDiagram participant Client participant API_GW as API Gateway participant Lambda as Lambda (Auth + Routing) participant Fargate as ECS Fargate (Core API) participant SQS as SQS Queue participant Worker as Lambda (Async Worker) participant DB as Aurora PostgreSQL Client->>API_GW: HTTPS Request API_GW->>Lambda: JWT validation + rate check Lambda->>Fargate: Forward validated request Fargate->>DB: Query (pooled conn via RDS Proxy) DB-->>Fargate: Result Fargate->>SQS: Enqueue async task (if needed) Fargate-->>Client: Synchronous response <150ms SQS->>Worker: Trigger background Lambda Worker->>DB: Write async updates

The pattern that emerges from these constraints is a hybrid:

Lambda for:
- API Gateway integrations (auth, routing, lightweight transformation)
- Async/event-driven workloads (SQS consumers, S3 triggers, EventBridge handlers)
- Scheduled jobs under 15 minutes
- Edge compute (Lambda@Edge, CloudFront Functions)

Containers for:
- Core API servers with latency SLAs
- Services that maintain connection pools
- Long-running background workers
- Workloads with predictable sustained load

The fintech team I mentioned at the start moved their payment API core to Fargate (with a dedicated RDS Proxy connection pool per service), kept Lambda for their event handlers (fraud scoring trigger, notification dispatch, audit log writers), and put a thin Lambda layer at the API Gateway for JWT validation. P95 latency on the payment confirmation flow dropped from 1,200ms to 140ms. The Lambda-to-Fargate cold start boundary was eliminated by ensuring Lambda functions called Fargate's internal ALB endpoint, not Lambda-to-Lambda.


Debugging the Boundary: Where Hybrid Architectures Break

The hardest part of hybrid architectures isn't building them — it's debugging them when they fail. Here are the non-obvious failure modes I've encountered.

Cold Start Cascade

Lambda function A calls Lambda function B (anti-pattern, but common). During a cold-start event, both functions are initializing simultaneously. The timeout on function A expires before function B finishes initializing. Function A retries. Now you have two cold-start chains in flight.

Fix: Use SQS as a buffer between Lambda functions. Lambda A writes to queue; Lambda B reads from queue. The timing decouples.

Connection Pool Starvation at Scale-Out

ECS Fargate service scales from 5 to 50 instances during a traffic spike. Each instance opens 10 connections to Aurora. 50 × 10 = 500 connections. Your Aurora writer instance has max_connections = 360. Every new container fails on startup with too many clients.

Mitigation: RDS Proxy handles connection multiplexing. With RDS Proxy, 500 Fargate containers can share a pool of 90 actual database connections. The proxy queues and multiplexes. Cost: ~$22/month for the proxy endpoint.

Lambda Throttling Propagating to Containers

Lambda concurrency limits are regional and account-wide. If your async Lambda workers (processing SQS messages) hit the concurrency ceiling, SQS messages back up. The queue depth grows. Your Fargate API, which reads queue depth via CloudWatch for business logic, starts showing stale state. Users see inconsistent data.

Fix: Set reserved concurrency on critical Lambda functions. Monitor SQS ApproximateNumberOfMessagesNotVisible alongside queue depth.

stateDiagram-v2 [*] --> Healthy: Normal operation Healthy --> LambdaThrottle: Concurrency limit hit LambdaThrottle --> QueueBackpressure: SQS messages accumulate QueueBackpressure --> StaleState: API reads stale queue depth StaleState --> InconsistentUX: Users see bad data InconsistentUX --> Investigation: Alert fires Investigation --> ReservedConcurrency: Root cause found ReservedConcurrency --> Healthy: Mitigation deployed LambdaThrottle --> ReservedConcurrency: Proactive fix

Implementation Guide: Building the Hybrid Foundation

Here's the Terraform pattern I use for the Lambda + Fargate hybrid setup:

# fargate_api.tf — core API service
resource "aws_ecs_service" "api" {
  name            = "core-api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = var.api_desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnets
    security_groups  = [aws_security_group.api.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "api"
    container_port   = 8080
  }

  # Scale independently from Lambda layer
  lifecycle {
    ignore_changes = [desired_count]
  }
}

# KEDA autoscaling via custom metrics
resource "aws_appautoscaling_target" "api" {
  max_capacity       = 50
  min_capacity       = 2
  resource_id        = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.api.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

resource "aws_appautoscaling_policy" "api_cpu" {
  name               = "api-cpu-tracking"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.api.resource_id
  scalable_dimension = aws_appautoscaling_target.api.scalable_dimension
  service_namespace  = aws_appautoscaling_target.api.service_namespace

  target_tracking_scaling_policy_configuration {
    target_value = 65.0  # 65% CPU target — leaves headroom for spikes
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
    scale_in_cooldown  = 180  # 3 min cooldown prevents thrashing
    scale_out_cooldown = 30
  }
}
# lambda_gateway.tf — thin auth + routing layer
resource "aws_lambda_function" "api_gateway" {
  function_name = "api-gateway-auth"
  runtime       = "nodejs20.x"
  handler       = "index.handler"

  # Critical: reserved concurrency isolates this from account limits
  reserved_concurrent_executions = 500

  environment {
    variables = {
      FARGATE_ALB_URL   = aws_lb.api.dns_name
      JWT_PUBLIC_KEY_ARN = aws_secretsmanager_secret.jwt_public_key.arn
    }
  }

  # VPC config — needed to reach internal ALB
  vpc_config {
    subnet_ids         = var.private_subnets
    security_group_ids = [aws_security_group.lambda_egress.id]
  }

  # SnapStart — cuts cold start from ~400ms to ~120ms for Node.js
  snap_start {
    apply_on = "PublishedVersions"
  }
}

The Lambda function then does minimal work — JWT verification (cached public key), basic rate limit check (DynamoDB), and a plain HTTP forward to the internal Fargate ALB. No business logic. Under 50ms of added latency at warm invocation.

// lambda/index.js — gateway handler
import { verify } from 'jsonwebtoken';
import { getPublicKey } from './key-cache.js';  // 5-min in-memory cache

export async function handler(event) {
  const token = event.headers?.authorization?.replace('Bearer ', '');

  if (!token) {
    return { statusCode: 401, body: JSON.stringify({ error: 'missing_token' }) };
  }

  try {
    const publicKey = await getPublicKey();  // cached, ~0ms after first warm
    const decoded = verify(token, publicKey, { algorithms: ['RS256'] });

    // Forward to Fargate with decoded user context injected
    const response = await fetch(`${process.env.FARGATE_ALB_URL}${event.path}`, {
      method: event.httpMethod,
      headers: {
        ...event.headers,
        'X-User-ID': decoded.sub,
        'X-User-Roles': decoded.roles.join(','),
      },
      body: event.body,
    });

    return {
      statusCode: response.status,
      headers: Object.fromEntries(response.headers),
      body: await response.text(),
    };
  } catch (err) {
    return { statusCode: 401, body: JSON.stringify({ error: 'invalid_token' }) };
  }
}

Production Considerations: What Nobody Tells You

Cost Monitoring Across the Hybrid

The biggest operational gotcha with hybrid architectures is that your costs are now spread across multiple billing dimensions: Lambda invocations + GB-seconds, Fargate vCPU-hours + GB-hours, RDS Proxy, NAT Gateway data transfer (Lambda in VPC → Fargate internal ALB still crosses NAT if misconfigured).

Set up AWS Cost Explorer tags from day one. Tag every resource with service, environment, and tier. Without tagging discipline, tracing a $3,000 monthly overspend to a misconfigured NAT Gateway in the Lambda VPC config takes three days of archaeology.

Observability: Stitching Lambda + Container Traces

OpenTelemetry W3C trace context (traceparent header) is the only practical way to stitch Lambda and Fargate traces into a single end-to-end view. Your Lambda gateway must propagate the trace ID into the Fargate ALB request headers, and your Fargate service must extract and continue the trace.

AWS X-Ray supports this natively if you're all-in on X-Ray, but it has poor sampling control and expensive at high volume. For production use, I recommend Grafana Tempo or Honeycomb with OpenTelemetry SDK in both the Lambda and container layers. You get correlated traces across the Lambda-to-container boundary without per-span cost anxiety.

Gradual Migration Strategy

If you're migrating an existing monolith to this hybrid pattern, don't try to do it all at once. The sequence that works:

  1. Extract background jobs to Lambda first (lowest risk, no latency requirements)
  2. Move scheduled tasks (cron jobs, reports) to Lambda
  3. Extract stateless API endpoints one at a time to Fargate microservices
  4. Move authentication layer to Lambda@Edge or Lambda gateway last (highest impact if wrong)

Each step should be independently deployable and rollback-capable.


Comparison and Tradeoffs Summary

Comparison matrix: Serverless vs Containers across 8 key dimensions
Dimension Lambda/Serverless ECS Fargate/Containers Hybrid
Cold start latency 80-3500ms (runtime-dependent) 5-45s (one-time per instance) Low for steady traffic
Cost at low volume Excellent (pay-per-invocation) Higher (minimum instance floor) Good
Cost at high sustained volume Can exceed containers Excellent Optimal
Operational complexity Low Medium Medium-High
Developer experience Simple deploys Dockerfile + orchestration More moving parts
Max execution time 15 minutes Unlimited Unlimited
Stateful workloads Difficult Native Best of both
Observability Harder to trace Standard APM applies Requires trace propagation
Auto-scaling Native, instant Seconds-to-minutes Native per layer

Conclusion

The serverless-vs-containers debate is over. Both won — in different places.

The engineering work in 2026 is less "which one" and more "where exactly do you draw the line." That requires understanding the actual mechanics (Firecracker cold starts, Fargate scheduling, database connection pooling), running the cost math for your specific load shape, and designing the observability layer to stitch the two worlds together before you're debugging at 2am.

The fintech team's story isn't unusual. Most teams that commit hard to one model eventually hit its limits. The teams building reliable, cost-efficient systems in 2026 are the ones who defined the boundary deliberately, not by accident.

Start with the decision framework above. Run the benchmark numbers for your workload. And if you're building the hybrid, do the trace propagation work from day one — retrofitting observability into a Lambda + Fargate architecture after it's in production is a miserable experience I'd spare anyone.


Sources

  1. AWS Lambda — SnapStart documentation and performance benchmarks — AWS, 2026
  2. Firecracker: Lightweight Virtualization for Serverless Applications — NSDI '20 paper — Agache et al., USENIX 2020
  3. Amazon ECS + KEDA autoscaling patterns — AWS Containers Blog, 2025
  4. OpenTelemetry W3C Trace Context — Trace Context Level 1 spec — W3C, 2021
  5. RDS Proxy performance benchmarks — AWS, 2026

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-19 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Friday, April 17, 2026

Container Security in 2026: Multi-Stage Builds, Distroless Images, and Supply Chain Security

Hero image

Introduction

Container security is not a checkbox. It is a layered discipline that spans your build pipeline, base image choices, runtime configuration, secrets handling, and software supply chain. Most teams get some of this right some of the time — but the gaps between layers are where breaches happen.

The threat landscape in 2026 looks different than it did in 2020. Supply chain attacks are now the dominant vector for container compromises. The SolarWinds pattern — compromise a build tool or base image rather than the target directly — has been replicated across dozens of incidents. Dependency confusion attacks, malicious packages injected into public registries, and tampered base images are all confirmed, documented attack paths. Meanwhile, misconfigured runtime permissions remain the most common root cause of container escapes in post-incident reports.

This post covers the full container security stack for production engineering teams: multi-stage builds that eliminate build-time bloat and attack surface, distroless and minimal base images that have near-zero CVE counts, non-root user enforcement at both the Docker and Kubernetes layers, CI-integrated image scanning with SBOM generation, runtime security profiles that limit syscall exposure, secrets management patterns that keep credentials out of image layers and environment variables, and supply chain security tooling (cosign, syft, Sigstore) that lets you cryptographically verify what you're running. Each section includes concrete, runnable code you can adapt directly.

The goal is a hardened container that is small, scannable, signed, secrets-free, and running with the minimum privilege it needs to do its job.


1. Multi-Stage Builds: Ship the Artifact, Not the Toolchain

The most impactful single change most teams can make to container security is adopting multi-stage builds. The principle is simple: you need a fully equipped build environment to compile and package your software, but you do not need that environment at runtime. Every tool you ship — compilers, build systems, package managers, debugging utilities — is attack surface that can be exploited after a container is compromised.

Multi-stage builds let you define a builder stage with everything needed to compile, then copy only the final artifact into a minimal runtime image.

Go application: 250MB → 8MB

# syntax=docker/dockerfile:1.7

# Stage 1: Builder
FROM golang:1.22-alpine AS builder

WORKDIR /src

# Copy dependency manifests first (cache layer)
COPY go.mod go.sum ./
RUN go mod download

# Copy source and build a statically linked binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
    -ldflags="-w -s -extldflags=-static" \
    -trimpath \
    -o /out/server \
    ./cmd/server

# Stage 2: Distroless runtime (no shell, no package manager)
FROM gcr.io/distroless/static-debian12:nonroot

# Copy only the compiled binary
COPY --from=builder /out/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

The -ldflags="-w -s" flags strip debug info and symbol tables (smaller binary, nothing for attackers to symbolize). -trimpath removes local build paths from stack traces (privacy). CGO_ENABLED=0 ensures no C runtime dependency — the binary runs on any Linux kernel without libc.

Result: the builder image is ~350MB with the Go toolchain. The final runtime image is ~8MB (distroless/static base is ~2MB, binary adds the rest). The runtime image contains no shell, no package manager, no compiler — only the binary and the minimal system libraries it needs.

Python application with dependency isolation

# syntax=docker/dockerfile:1.7

# Stage 1: Dependency builder
FROM python:3.12-slim AS builder

WORKDIR /app

# Install build tools only in builder
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libffi-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies into a prefix we can copy
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim AS runtime

# Create non-root user
RUN useradd --system --no-create-home --shell /sbin/nologin appuser

WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application source (no build tools present)
COPY --chown=appuser:appuser src/ ./src/

USER appuser

EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

The key discipline here: build-essential and libffi-dev appear only in the builder stage. They are needed to compile native extensions like cryptography or uvloop. The runtime stage gets a fresh python:3.12-slim and only receives the pre-built packages via COPY --from=builder. No compiler, no build headers — an attacker who achieves code execution cannot install new compiled tools.

Layer caching discipline: always copy go.mod/requirements.txt before source code. Docker caches layers by content hash. If you copy source first, a single source line change invalidates the entire dependency installation cache. Structuring for cache locality can cut CI build times by 60-80% on large projects.

flowchart TD A[Source Code] --> B[Builder Stage\nFull toolchain + deps] B --> C[Compile / Package\ngo build / pip install] C --> D{Copy artifact only} D --> E[Runtime Stage\nMinimal base image] E --> F[Final Image\n~8MB, no compiler] style B fill:#ff6b6b,color:#fff style E fill:#51cf66,color:#fff style F fill:#339af0,color:#fff

Architecture diagram

2. Distroless and Minimal Base Images: Eliminate the Attack Surface

A container image is a filesystem. Every binary, library, and configuration file in that filesystem is a potential exploit path. The traditional approach — start with ubuntu:22.04 because it is familiar — ships a complete operating system with hundreds of packages, most of which your application never touches. Each of those packages can carry CVEs.

Google's distroless images strip this down to the absolute minimum: only the language runtime and direct system dependencies your application needs. No shell (/bin/sh, /bin/bash), no package manager (apt, apk), no coreutils (ls, chmod, curl). The attack surface collapses.

Image comparison by CVE count (2026 data)

Base Image Size Typical CVE Count Has Shell Has Package Manager
ubuntu:22.04 ~70MB 150-200 CVEs Yes Yes (apt)
debian:bookworm-slim ~75MB 100-150 CVEs Yes Yes (apt)
alpine:3.19 ~7MB 5-20 CVEs Yes (ash) Yes (apk)
gcr.io/distroless/base-debian12 ~20MB 0-5 CVEs No No
gcr.io/distroless/static-debian12 ~2MB 0 CVEs No No
scratch 0MB 0 CVEs No No

When to use each:

  • scratch: statically compiled binaries with zero external dependencies (Go CGO_ENABLED=0, Rust with musl target). Nothing else will work — no DNS resolver, no TLS certs. Must bundle /etc/ssl/certs/ca-certificates.crt and /etc/passwd if your app needs them.
  • distroless/static: statically compiled binaries that need TLS certs and basic system files. Google includes these. Best for Go, Rust.
  • distroless/base: dynamically linked binaries that need glibc. Includes OpenSSL, glibc, libssl. Best for applications with C extensions.
  • distroless/python3, distroless/nodejs: pre-built distroless variants for interpreted runtimes. Google maintains these.
  • alpine: when you need a shell for debugging or entrypoint scripts. Vastly smaller than debian variants. Acceptable for development, avoid for production where possible.

The no-shell constraint eliminates RCE pivot

If an attacker exploits a vulnerability in your application and achieves command execution, their next move is always to run additional commands: download a reverse shell, enumerate the filesystem, escalate privileges. No shell means no shell commands. They are limited to what your application binary can do. Combined with a read-only root filesystem (covered in section 5), the attacker's ability to establish persistence collapses.

# Scratch example: Go binary with bundled TLS certs
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache ca-certificates
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /out/server ./cmd/server

FROM scratch
# Copy TLS certs from builder
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy passwd for non-root UID (see section 3)
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /out/server /server
USER nobody
ENTRYPOINT ["/server"]

Alpine trade-offs: Alpine uses musl libc instead of glibc. This can cause subtle behavioral differences in applications compiled against glibc (memory allocation patterns, DNS resolution, locale handling). Test Alpine compatibility explicitly. Alpine is excellent for intermediate build stages. Distroless is preferable for final runtime stages when you want glibc compatibility with zero CVE count.

flowchart LR subgraph ubuntu["ubuntu:22.04"] direction TB U1[Shell + Coreutils] U2[Package Manager] U3[System Libraries ~200] U4[Your App] end subgraph distroless["distroless/static"] direction TB D1[TLS Certs] D2[Timezone Data] D3[Your App] end ubuntu -- "CVEs: 150-200" --> Vuln[/Attack Surface\] distroless -- "CVEs: 0" --> Safe[/Minimal Surface\] style ubuntu fill:#ff6b6b,color:#fff style distroless fill:#51cf66,color:#fff style Vuln fill:#ff6b6b,color:#fff style Safe fill:#51cf66,color:#fff
Comparison visual

3. Non-Root User Enforcement: Never Run as UID 0

Running a container process as root (UID 0) is the single most common container misconfiguration. When a container runs as root and achieves a container escape via a kernel vulnerability, the attacker arrives on the host as root. Even within the container, a root process can read any file, write to any path, and load kernel modules if capabilities are not explicitly dropped.

Dockerfile: create and use a non-root user

FROM gcr.io/distroless/base-debian12 AS base

# For images that support useradd (non-distroless build stage):
FROM debian:bookworm-slim AS setup
RUN groupadd --gid 10001 appgroup && \
    useradd \
      --uid 10001 \
      --gid appgroup \
      --no-create-home \
      --shell /sbin/nologin \
      appuser

FROM gcr.io/distroless/base-debian12
# Carry over the passwd/group entries from setup stage
COPY --from=setup /etc/passwd /etc/passwd
COPY --from=setup /etc/group /etc/group

COPY --chown=10001:10001 --from=builder /out/server /server

USER 10001

ENTRYPOINT ["/server"]

Using a numeric UID (USER 10001) rather than a username (USER appuser) is more robust: Kubernetes admission controllers and OPA policies can check numeric UIDs reliably. Username resolution depends on /etc/passwd being present in the image.

Kubernetes securityContext: enforce at the pod level

Even if an image is built to run as non-root, nothing prevents someone from overriding it with --user root in a docker run command or a Kubernetes pod spec. Kubernetes securityContext enforces the constraint at the scheduler level:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  template:
    spec:
      # Pod-level: applies to all containers
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault   # see section 5
      containers:
        - name: api
          image: registry.example.com/api-server:v1.2.3@sha256:abc123...
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
              add:
                - NET_BIND_SERVICE  # only if port < 1024
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache/app
      volumes:
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}

runAsNonRoot: true causes the Kubernetes admission controller to reject any pod whose container image is configured to run as root — even if the Dockerfile doesn't specify a USER directive. allowPrivilegeEscalation: false prevents the process from gaining new privileges via setuid binaries or file capabilities.

The secrets-as-root problem: when secrets are mounted and the container runs as root, the mounted secret files are readable by anyone who can exec into the container. With a non-root user and fsGroup set, Kubernetes mounts secret volumes with the correct group ownership so only the application user can read them. This is the difference between "attacker reads your database credentials" and "attacker gets a permission denied error."


4. Image Scanning in CI: Catch CVEs Before They Ship

Scanning at build time is table stakes. Effective scanning also runs on a schedule against deployed images (new CVEs are published daily; an image clean today may have critical vulnerabilities tomorrow) and generates SBOMs for downstream audit.

Trivy in GitHub Actions: fail on critical CVEs

# .github/workflows/container-security.yml
name: Container Security Scan

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    # Daily scan of deployed images
    - cron: '0 6 * * *'

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write   # for GitHub Security tab upload
      packages: write          # for GHCR push

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image (do not push yet)
        uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          tags: ${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          outputs: type=docker,dest=/tmp/image.tar
          load: true

      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          input: /tmp/image.tar
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'           # fail the build
          ignore-unfixed: true     # skip CVEs with no fix available
          vuln-type: 'os,library'

      - name: Upload scan results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: 'trivy-results.sarif'

      - name: Generate SBOM with Syft
        uses: anchore/sbom-action@v0
        with:
          image: ${{ github.repository }}:${{ github.sha }}
          format: spdx-json
          output-file: sbom.spdx.json

      - name: Attest SBOM (Sigstore)
        uses: actions/attest-sbom@v1
        with:
          subject-name: ghcr.io/${{ github.repository }}
          subject-digest: ${{ steps.build.outputs.digest }}
          sbom-path: sbom.spdx.json

      - name: Push to registry (only if scan passes)
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha

The workflow deliberately builds twice: once to a local tar for scanning, then pushes only if the scan passes. This prevents pushing a vulnerable image to the registry even if the signing step fails.

Grype as an alternative: Anchore Grype is lighter weight and integrates tightly with Syft for SBOM-driven scanning:

# Install
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

# Scan an image
grype docker:myapp:latest --fail-on critical

# Scan an SBOM (faster for scheduled rescans — no need to pull image)
grype sbom:sbom.spdx.json --fail-on high

# Output JSON for pipeline integration
grype docker:myapp:latest -o json > scan-results.json

Scheduled base image rescanning: your CI only scans when code changes. New CVEs are published against base images you've already deployed. Add a scheduled job that pulls deployed image digests from your registry and rescans against the current vulnerability database:

#!/bin/bash
# scan-deployed.sh — run daily via cron or CI schedule

REGISTRY="ghcr.io/myorg"
IMAGES=("api-server" "worker" "scheduler")

for IMAGE in "${IMAGES[@]}"; do
  DIGEST=$(crane digest "${REGISTRY}/${IMAGE}:latest")
  echo "Scanning ${IMAGE}@${DIGEST}"
  trivy image \
    --severity CRITICAL \
    --exit-code 1 \
    --ignore-unfixed \
    "${REGISTRY}/${IMAGE}@${DIGEST}" || \
    notify-slack "CRITICAL CVE in deployed image: ${IMAGE}"
done

Image signing with cosign

# Install cosign
brew install cosign  # or: go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# Generate a key pair (or use keyless via OIDC in CI)
cosign generate-key-pair

# Sign an image after push
cosign sign --key cosign.key \
  ghcr.io/myorg/api-server:latest

# Verify before deployment
cosign verify --key cosign.pub \
  ghcr.io/myorg/api-server:latest

# Keyless signing in GitHub Actions (uses Fulcio CA + OIDC)
cosign sign \
  --rekor-url https://rekor.sigstore.dev \
  ghcr.io/myorg/api-server@sha256:abc123...

Keyless signing in CI uses the GitHub OIDC token to prove the image was built by a specific GitHub Actions workflow. The signature is recorded in the Rekor transparency log — publicly auditable, tamper-evident. No key management required.


5. Runtime Security: Contain the Blast Radius

Image security determines what enters the runtime. Runtime security determines what the running process can do. The two layers are independent — a perfectly hardened image can still be exploited at runtime if it runs with excessive capabilities.

Seccomp profiles: restrict syscalls

The Linux kernel exposes ~350 syscalls. A typical web server needs perhaps 40. Every additional syscall is a potential exploitation vector (kernel vulnerabilities are often syscall-triggered). Seccomp (Secure Computing Mode) lets you define an allowlist:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "read", "write", "open", "close", "stat", "fstat",
        "mmap", "mprotect", "munmap", "brk", "access",
        "execve", "exit", "wait4", "getpid", "gettid",
        "socket", "connect", "accept", "sendto", "recvfrom",
        "bind", "listen", "getsockname", "setsockopt", "getsockopt",
        "clone", "fork", "futex", "nanosleep", "clock_gettime",
        "epoll_create1", "epoll_ctl", "epoll_wait",
        "signalfd4", "timerfd_create", "eventfd2"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Apply in Kubernetes:

securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/api-server.json  # path under /var/lib/kubelet/seccomp/

RuntimeDefault is the Kubernetes-maintained default seccomp profile. It blocks the most dangerous syscalls (ptrace, kexec_load, open_by_handle_at) without requiring a custom profile. Use it as a minimum baseline; add a custom profile for defense-in-depth.

Linux capabilities: drop ALL, add back only what's needed

Linux capabilities divide root's omnipotence into ~40 distinct privileges. The security principle: drop all capabilities, add back only the specific ones your application requires.

# In Kubernetes securityContext
capabilities:
  drop:
    - ALL
  add:
    - NET_BIND_SERVICE   # bind to port < 1024 (if needed)
    # Common additions:
    # - CHOWN            # change file ownership (avoid if possible)
    # - SETUID/SETGID    # only if app needs to drop privileges post-start

Most web servers and APIs need zero capabilities if they run on port 8080 or higher and the filesystem is owned correctly. NET_BIND_SERVICE is only needed for port 80/443 — use a reverse proxy (nginx, Envoy) to terminate on 80/443 and forward to 8080 internally.

Read-only root filesystem

securityContext:
  readOnlyRootFilesystem: true

# Mount writable volumes only for paths that need them
volumeMounts:
  - name: tmp
    mountPath: /tmp
  - name: app-cache
    mountPath: /var/cache/myapp
  - name: logs
    mountPath: /var/log/myapp

volumes:
  - name: tmp
    emptyDir: {}
  - name: app-cache
    emptyDir:
      sizeLimit: 500Mi
  - name: logs
    emptyDir: {}

With a read-only filesystem, an attacker who achieves code execution cannot write malware, modify application binaries, or create persistent backdoors. The filesystem state is immutable — identical to the image layer on every restart. emptyDir volumes provide writable scratch space without compromising this.

flowchart TD A[Container Starts] --> B{seccomp profile\nloaded?} B -- Yes --> C[Syscall filter active\n~40 allowed of ~350] B -- No --> X1[ALL syscalls allowed\nKernel exploit surface exposed] C --> D{Drop ALL\ncapabilities?} D -- Yes --> E[No root powers\nNET_BIND_SERVICE only] D -- No --> X2[Root capabilities active\nCHOWN, KILL, SYS_ADMIN etc.] E --> F{Read-only\nrootfs?} F -- Yes --> G[Immutable filesystem\nNo persistence possible] F -- No --> X3[Writable rootfs\nMalware can persist] G --> H[Hardened Runtime\nBlast radius contained] style X1 fill:#ff6b6b,color:#fff style X2 fill:#ff6b6b,color:#fff style X3 fill:#ff6b6b,color:#fff style H fill:#51cf66,color:#fff

6. Secrets Management: Keep Credentials Out of Image Layers

The three most common ways secrets end up in container images — all of them wrong:

Wrong #1: Environment variables in Dockerfile

# NEVER DO THIS
ENV DATABASE_URL="postgresql://user:password@prod-db:5432/app"
ENV API_KEY="sk-live-abc123..."

Environment variables are stored in the image manifest. They appear in docker inspect <container>, docker history <image>, and in Kubernetes pod specs visible to anyone with kubectl get pod -o yaml. Even if the container is stopped, the credentials persist in the image layer indefinitely.

Wrong #2: COPY or ADD credentials into the image

# ALSO NEVER DO THIS — even with a subsequent RUN rm
COPY .env /app/.env
RUN pip install -r requirements.txt
RUN rm /app/.env   # THIS DOES NOT HELP

Docker layers are content-addressed and immutable. RUN rm /app/.env creates a new layer that hides the file but does not delete it from the underlying layer. docker history --no-trunc and layer extraction tools will retrieve the credentials from the earlier layer. This has been exploited against real registries.

Right approach #1: Docker secrets (Swarm / BuildKit)

# syntax=docker/dockerfile:1.7

FROM python:3.12-slim AS builder

# Mount a secret during build — never written to any layer
RUN --mount=type=secret,id=pip_config \
    pip install \
    --index-url "$(cat /run/secrets/pip_config)" \
    --no-cache-dir \
    -r requirements.txt
# Pass secret at build time via BuildKit
docker buildx build \
  --secret id=pip_config,src=./private-pip.conf \
  .

The secret is available only during the RUN step as a tmpfs mount. It never appears in any image layer. docker history shows no trace of it.

Right approach #2: Kubernetes Secrets mounted as files

# Create the secret
kubectl create secret generic db-credentials \
  --from-literal=url='postgresql://user:pass@db:5432/app' \
  --from-literal=password='s3cr3t'

# Mount in pod spec
spec:
  containers:
    - name: api
      volumeMounts:
        - name: db-creds
          mountPath: /run/secrets/db
          readOnly: true
  volumes:
    - name: db-creds
      secret:
        secretName: db-credentials
        defaultMode: 0400   # owner read-only

Kubernetes mounts secrets as tmpfs — memory-only, not written to node disk. With defaultMode: 0400 and runAsUser: 10001 plus fsGroup: 10001, only the application user can read the files.

Right approach #3: HashiCorp Vault agent injection

For secrets rotation and audit logging, Vault agent injection is the production standard:

# Vault agent injects secrets as init container, writes to shared tmpfs
annotations:
  vault.hashicorp.com/agent-inject: "true"
  vault.hashicorp.com/agent-inject-secret-db-creds: "secret/data/myapp/db"
  vault.hashicorp.com/agent-inject-template-db-creds: |
    {{- with secret "secret/data/myapp/db" -}}
    DATABASE_URL=postgresql://{{ .Data.data.username }}:{{ .Data.data.password }}@db:5432/app
    {{- end }}
  vault.hashicorp.com/role: "myapp"

Vault injects an init container that authenticates via Kubernetes service account, fetches the secret, and writes it to a shared in-memory volume at /vault/secrets/. Your application reads it as a file. Vault agent sidecar handles rotation — when the secret expires, the file is rewritten without restarting your pod.

Right approach #4: AWS Secrets Manager via CSI driver

apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: aws-secrets
spec:
  provider: aws
  parameters:
    objects: |
      - objectName: "prod/myapp/db-credentials"
        objectType: "secretsmanager"
        jmesPath:
          - path: "password"
            objectAlias: "db-password"
          - path: "username"
            objectAlias: "db-username"

The CSI driver mounts AWS Secrets Manager values directly as files without ever storing them in a Kubernetes Secret object. This avoids etcd storage entirely.


7. Supply Chain Security: Sign, Verify, and Audit Everything

Supply chain attacks target the gap between "the code you wrote" and "the binary running in production." This gap includes every dependency, every base image, every build tool, and every CI step. Closing that gap requires cryptographic attestation at each stage.

Syft: generate SBOMs

A Software Bill of Materials (SBOM) is a machine-readable inventory of every package and library in your image. It enables downstream CVE scanning, license compliance checks, and incident response (when a new vulnerability is published, you can immediately query which of your images contain the affected package).

# Install syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# Generate SBOM in SPDX format
syft ghcr.io/myorg/api-server:latest -o spdx-json > sbom.spdx.json

# Generate in CycloneDX format (better tool support)
syft ghcr.io/myorg/api-server:latest -o cyclonedx-json > sbom.cdx.json

# Scan the SBOM for vulnerabilities (faster than scanning the image)
grype sbom:sbom.spdx.json --fail-on critical

# Attest the SBOM to the image (stored in registry alongside image)
cosign attest \
  --predicate sbom.spdx.json \
  --type spdxjson \
  ghcr.io/myorg/api-server@sha256:abc123...

Cosign and Sigstore: cryptographic image signing

# Full signing workflow in CI (keyless, using OIDC)
# 1. Build and push the image
docker buildx build --push \
  -t ghcr.io/myorg/api-server:v1.2.3 .

# 2. Get the digest of what was pushed
DIGEST=$(crane digest ghcr.io/myorg/api-server:v1.2.3)

# 3. Sign (records to Rekor transparency log)
cosign sign \
  ghcr.io/myorg/api-server@${DIGEST}

# 4. At deploy time, verify before running
cosign verify \
  --certificate-identity-regexp "^https://github.com/myorg/myrepo/.github/workflows/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/api-server@${DIGEST}

The Rekor transparency log (rekor.sigstore.dev) is a public, append-only, cryptographically verifiable ledger. Every signature is recorded with the signing identity, timestamp, and image digest. You can audit exactly which CI run signed which image.

OPA/Gatekeeper: enforce trusted base image policy

# OPA ConstraintTemplate: require signed images from approved registries
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: requiresignedimages
spec:
  crd:
    spec:
      names:
        kind: RequireSignedImages
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package requiresignedimages

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not startswith(container.image, "ghcr.io/myorg/")
          msg := sprintf("Image %v is not from the approved registry", [container.image])
        }

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not regex.match(".*@sha256:[a-f0-9]{64}$", container.image)
          msg := sprintf("Image %v must be pinned to a digest, not a tag", [container.image])
        }

The digest-pinning rule is critical: image tags are mutable. myapp:latest can be silently overwritten by an attacker who gains registry access. Referencing by digest (@sha256:abc...) is immutable — the content is cryptographically bound to the identifier.


8. Production Container Hardening Checklist

A reference checklist for production deployments. Every item should be verifiable in CI or via admission controller policy.

Category Check Tool/Method
Build Multi-stage build: runtime image has no compiler/build tools docker history
Build No secrets in ENV or COPY — use BuildKit --mount=type=secret docker history --no-trunc
Build Base image pinned to digest, not tag Dockerfile inspection
Build .dockerignore excludes .git, .env, credentials, test data .dockerignore review
Image Distroless or Alpine base (not ubuntu/debian full) Image scan
Image Final image < 50MB (ideally < 15MB for Go/Rust) docker images
Image Trivy/Grype scan passes with no unpatched CRITICAL CVEs CI gate
Image SBOM generated and attested cosign attest
Image Image signed with cosign cosign verify
Image Tagged with git digest, not latest Registry policy
Runtime USER directive sets non-root UID in Dockerfile Dockerfile inspection
Runtime runAsNonRoot: true in Kubernetes securityContext OPA policy
Runtime readOnlyRootFilesystem: true OPA policy
Runtime allowPrivilegeEscalation: false OPA policy
Runtime capabilities: drop: [ALL] OPA policy
Runtime seccompProfile: RuntimeDefault or custom profile OPA policy
Runtime Resource limits set (CPU + memory) OPA policy
Runtime No hostPID, hostNetwork, hostIPC OPA policy
Secrets No secrets in environment variables kubectl get pod -o yaml audit
Secrets Secrets mounted as files via Kubernetes Secret or Vault Pod spec review
Secrets Secret volumes mounted with readOnly: true Pod spec review
Secrets Vault/CSI driver used for rotation-capable secrets Vault audit log
Network NetworkPolicy restricts ingress/egress to required paths kubectl get networkpolicy
Scanning Base images rescanned daily (not just at build time) Scheduled CI job
Audit Falco or similar runtime threat detection enabled Falco rules active

Conclusion

Container security is most effective when it is automated and enforced by policy — not when it depends on individual developers remembering to do the right thing. The patterns in this post compose into a layered defense: multi-stage builds eliminate build-time bloat, distroless images reduce the CVE surface to near zero, non-root enforcement removes the most common privilege escalation path, image scanning catches known vulnerabilities before they reach production, runtime security profiles contain the blast radius if something does get exploited, secrets management ensures credentials never appear in image layers or environment variables, and supply chain tooling provides cryptographic proof of what you're actually running.

None of these layers is sufficient alone. A perfectly hardened image is worthless if the running container has allowPrivilegeEscalation: true. A well-enforced runtime policy is undermined if secrets are stored in environment variables visible to docker inspect. The checklist in section 8 is a dependency graph as much as a checklist — each item strengthens the others.

Start with the high-impact items: multi-stage builds and distroless base images reduce your attack surface by the largest margin for the least engineering effort. Add non-root enforcement and readOnlyRootFilesystem next — both are single-line changes in a Dockerfile and Kubernetes spec. Then layer in CI scanning, secrets management, and supply chain attestation as your team's capacity allows. The goal is a container that is immutable, minimal, scannable, signed, and running as close to zero privilege as its workload requires.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-04 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...