A container is an image, plus its runtime config, plus what it can reach. Every one of those is attack surface, and the default tendency is to pack in far more than the app actually needs: a full OS, a shell, a package manager, build tools, all sitting there as a toolkit for anyone who gets code execution. The single most effective thing you can do for container security is ship less.
Start with a smaller base
The base image sets your floor for size and vulnerabilities, so choose deliberately:
| Base | Size | Use |
|---|---|---|
ubuntu:24.04 | ~77 MB | Development |
node:22-slim | ~180 MB | Node |
gcr.io/distroless/nodejs22 | ~130 MB | Production |
alpine:3.20 | ~7 MB | Minimal |
scratch | 0 MB | Static Go binaries |
Distroless images are worth singling out: no shell, no package manager. An attacker who gets code execution lands somewhere they can barely move, because the tools they'd reach for aren't there.
Build in two stages
A multi-stage build lets you compile with the full toolchain, then ship only the result:
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]
The final image carries the app and its production dependencies. No build tools, no source code.
The build-time essentials
Run as a non-root user, so a container escape doesn't start as root:
RUN addgroup --system app && adduser --system --ingroup app app
USER app
Pin to a digest, not a moving tag:
FROM node:22.5.1-slim@sha256:abc123...
Keep secrets out of the layers, because anything you COPY in persists in the image history even if a later layer deletes it:
# BAD - persists in layer history
COPY .env /app/.env
# GOOD - BuildKit secret, never written to a layer
RUN --mount=type=secret,id=db_password \
DB_PASSWORD=$(cat /run/secrets/db_password) npm run setup
Scan, then sign
Scan every image in CI and fail the build on critical or high findings:
trivy image myapp:latest
grype myapp:latest
Be honest about what scanning does, though: it finds known CVEs in your dependencies. It won't find your own code bugs or your misconfigurations. Then sign what you ship, so deployment can verify it's yours and unmodified:
cosign sign --key cosign.key myregistry.com/myapp:latest
cosign verify --key cosign.pub myregistry.com/myapp:latest
Constrain it at runtime
Building a tight image is half the job; the runtime config is the other half. Run with a read-only filesystem, drop all Linux capabilities, restrict which pods can talk to which with network policies, and set resource limits so one container can't starve the rest.
It all comes back to the same lever. Every layer, tool, and capability you remove is one less thing to patch, scan, and defend.
