container-image-hardening
A careless Dockerfile ships a 1.2GB image running as root with the whole build toolchain and your .git history baked in. This skill builds the opposite: a small image with only the runtime, a non-root user, pinned bases, and fast cache-friendly builds. Small is not just cheaper — a smaller image has fewer packages, which means fewer CVEs and less attack surface.
Use when
- Writing or fixing a Dockerfile.
- Images are huge, slow to build, or rebuild fully on every code change.
- A container scan flags CVEs from base-image packages you don't use.
Multi-stage: build fat, ship thin
Separate the build environment from the runtime. Compile/install with the full toolchain in a builder stage, then copy only the built artifact into a minimal final stage. The final image never contains compilers, dev headers, or package caches.
# builder — has the toolchain
FROM node:20.11-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # cached unless lockfile changes
COPY . .
RUN npm run build && npm prune --omit=dev
# runtime — minimal, non-root
FROM gcr.io/distroless/nodejs20-debian12 AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER nonroot
EXPOSE 3000
CMD ["dist/main.js"]
Layer caching order (build speed)
Docker caches layers top-down and invalidates everything after the first changed layer. Order from least- to most-frequently-changed:
- Base image (rarely changes).
- System packages.
- Dependency manifests only (
package.json+lock, requirements.txt, go.mod) → install deps. This layer stays cached as long as deps don't change.
- Application source (changes every commit) → build.
The classic mistake is COPY . . before installing dependencies — now every source edit busts the dependency-install cache and reinstalls everything. Copy the manifest, install, then copy the source.
Minimal, pinned base
- Prefer distroless or alpine/-slim over a full
ubuntu/debian. Distroless has no shell and no package manager — a huge attack-surface reduction (and it means an attacker who lands RCE has no sh to pivot with).
- Pin the base by digest (
FROM node:20.11-slim@sha256:...) for reproducibility — a tag can be re-pushed; a digest can't.
- Update bases deliberately (renovate/dependabot) so you get CVE patches without surprise drift.
Run as non-root
- Create and switch to an unprivileged user (
USER nonroot/a numeric UID). A container escape as root is far worse than as an unprivileged user.
- Make the filesystem read-only at runtime where possible (
--read-only + a writable tmpfs for scratch); drop Linux capabilities you don't need.
- Don't
EXPOSE/bind privileged ports as root just to use :80 — bind high and map, or use a capability.
Don't leak secrets or bloat
.dockerignore first. Exclude .git, node_modules (rebuilt in-image), .env, secrets, **/*.pem, CI files, test fixtures. Without it, COPY . . bakes your git history and local secrets into a layer — and layers are forever, even if a later layer "removes" the file.
- Never
COPY a secret or ARG a secret you need at runtime — build args are visible in image history. Use BuildKit secret mounts (--mount=type=secret) for build-time secrets so they never persist in a layer; inject runtime secrets at run time (env/mounted file), never bake them.
- A secret removed in a later
RUN rm is still in the earlier layer. Removing ≠ gone. Don't add it in the first place.
Verify
- Scan the final image (Trivy/Grype) in CI; fail on fixable critical/high CVEs.
- Check the image size and layer count; a surprise jump means something (cache, source, secret) got baked in.
- Add a
HEALTHCHECK (or rely on the orchestrator's probe) so the platform knows when the container is actually serving.
- Confirm it runs as non-root:
docker run ... id should not be uid 0.
Procedure
- Write
.dockerignore before the Dockerfile.
- Multi-stage: toolchain in builder, minimal distroless/slim runtime, pinned by digest.
- Order layers least→most volatile; copy manifests + install before copying source.
- Add a non-root
USER; consider read-only rootfs + dropped caps.
- Route build-time secrets through BuildKit secret mounts; inject runtime secrets at run time.
- Scan in CI; verify size, non-root, and that no secret/
.git is in any layer.
Definition of done
- Multi-stage; final image has no build toolchain; base pinned by digest.
- Runs as non-root; minimal base (distroless/slim); reduced attack surface.
- Dependency layer stays cached across source-only changes.
.dockerignore excludes .git/secrets/node_modules; no secret in any layer or in image history.
- Image scanned in CI; size sane; healthcheck present.
1---2name: container-image-hardening3description: Build a small, secure, reproducible container image — multi-stage, non-root, pinned, minimal attack surface. Use when writing or fixing a Dockerfile, when images are huge or slow to build, or when a scan flags container CVEs. Covers layer caching order, multi-stage builds, distroless/minimal bases, non-root runtime, and the .dockerignore/secret-leak traps.4---56# container-image-hardening78A careless Dockerfile ships a 1.2GB image running as root with the whole build toolchain and your `.git` history baked in. This skill builds the opposite: a small image with only the runtime, a non-root user, pinned bases, and fast cache-friendly builds. Small is not just cheaper — a smaller image has fewer packages, which means fewer CVEs and less attack surface.910## Use when11- Writing or fixing a Dockerfile.12- Images are huge, slow to build, or rebuild fully on every code change.13- A container scan flags CVEs from base-image packages you don't use.1415## Multi-stage: build fat, ship thin16Separate the *build* environment from the *runtime*. Compile/install with the full toolchain in a builder stage, then copy only the built artifact into a minimal final stage. The final image never contains compilers, dev headers, or package caches.1718```dockerfile19# builder — has the toolchain20FROM node:20.11-bookworm-slim AS builder21WORKDIR /app22COPY package.json package-lock.json ./23RUN npm ci # cached unless lockfile changes24COPY . .25RUN npm run build && npm prune --omit=dev2627# runtime — minimal, non-root28FROM gcr.io/distroless/nodejs20-debian12 AS runtime29WORKDIR /app30COPY --from=builder /app/dist ./dist31COPY --from=builder /app/node_modules ./node_modules32USER nonroot33EXPOSE 300034CMD ["dist/main.js"]35```3637## Layer caching order (build speed)38Docker caches layers top-down and invalidates everything after the first changed layer. Order from least- to most-frequently-changed:391. Base image (rarely changes).402. System packages.413. **Dependency manifests only** (`package.json`+lock, `requirements.txt`, `go.mod`) → install deps. This layer stays cached as long as deps don't change.424. **Application source** (changes every commit) → build.4344The classic mistake is `COPY . .` *before* installing dependencies — now every source edit busts the dependency-install cache and reinstalls everything. Copy the manifest, install, *then* copy the source.4546## Minimal, pinned base47- Prefer **distroless** or **alpine**/**-slim** over a full `ubuntu`/`debian`. Distroless has no shell and no package manager — a huge attack-surface reduction (and it means an attacker who lands RCE has no `sh` to pivot with).48- **Pin the base by digest** (`FROM node:20.11-slim@sha256:...`) for reproducibility — a tag can be re-pushed; a digest can't.49- Update bases deliberately (renovate/dependabot) so you get CVE patches without surprise drift.5051## Run as non-root52- Create and switch to an unprivileged user (`USER nonroot`/a numeric UID). A container escape as root is far worse than as an unprivileged user.53- Make the filesystem read-only at runtime where possible (`--read-only` + a writable `tmpfs` for scratch); drop Linux capabilities you don't need.54- Don't `EXPOSE`/bind privileged ports as root just to use :80 — bind high and map, or use a capability.5556## Don't leak secrets or bloat57- **`.dockerignore` first.** Exclude `.git`, `node_modules` (rebuilt in-image), `.env`, secrets, `**/*.pem`, CI files, test fixtures. Without it, `COPY . .` bakes your git history and local secrets into a layer — and layers are forever, even if a later layer "removes" the file.58- **Never `COPY` a secret or `ARG` a secret you need at runtime** — build args are visible in image history. Use BuildKit secret mounts (`--mount=type=secret`) for build-time secrets so they never persist in a layer; inject runtime secrets at run time (env/mounted file), never bake them.59- A secret removed in a later `RUN rm` is still in the earlier layer. Removing ≠ gone. Don't add it in the first place.6061## Verify62- **Scan** the final image (Trivy/Grype) in CI; fail on fixable critical/high CVEs.63- Check the image size and layer count; a surprise jump means something (cache, source, secret) got baked in.64- Add a `HEALTHCHECK` (or rely on the orchestrator's probe) so the platform knows when the container is actually serving.65- Confirm it runs as non-root: `docker run ... id` should not be uid 0.6667## Procedure681. Write `.dockerignore` before the Dockerfile.692. Multi-stage: toolchain in builder, minimal distroless/slim runtime, pinned by digest.703. Order layers least→most volatile; copy manifests + install before copying source.714. Add a non-root `USER`; consider read-only rootfs + dropped caps.725. Route build-time secrets through BuildKit secret mounts; inject runtime secrets at run time.736. Scan in CI; verify size, non-root, and that no secret/`.git` is in any layer.7475## Definition of done76- Multi-stage; final image has no build toolchain; base pinned by digest.77- Runs as non-root; minimal base (distroless/slim); reduced attack surface.78- Dependency layer stays cached across source-only changes.79- `.dockerignore` excludes `.git`/secrets/`node_modules`; no secret in any layer or in image history.80- Image scanned in CI; size sane; healthcheck present.