DevOps for a SOC 2 audit
Part 1 — Docker as an evidence factory
A SOC 2 audit asks one question over and over: prove this control actually runs. The usual answer is screenshots and spreadsheets assembled in a panic the week before. The better answer is a pipeline that emits its own evidence — on every commit, machine-readable, straight into the analytical systems. This series documents that practice, starting where every build starts: the Dockerfile.
Goal
Make one multi-stage Dockerfile the evidence factory: every quality and security control (lint, unit tests, coverage, dependency audit) is a build stage; every stage's result is a machine-readable artifact; and every artifact is exportable by CI — so static code analyzers, vulnerability assessment platforms and the audit evidence store all ingest the same files the build produced, tied to a commit SHA. Evidence stops being a document someone writes; it becomes a by-product nobody can forget to produce.
The Dockerfile, step by step
This is a lightly annotated version of the production Dockerfile for a NestJS backend. Eleven
stages, one base, and a deliberate split: preparation stages that run a
control, and FROM scratch artifact stages that contain nothing but the
control's report.
The full multi-stage Dockerfile
# syntax=docker/dockerfile:1
FROM node:18.17.1-alpine AS base # pinned base — supply-chain 101
WORKDIR /usr/src/app
# dependency manifests first: the npm ci layer only rebuilds when the
# lockfile changes, not on every source edit
COPY --chown=node:node get_env_vars.sh ./
COPY --chown=node:node package*.json ./
RUN apk add --no-cache python3 make g++ # native module toolchain
COPY .npmrc .
RUN cp .npmrc "$HOME"
RUN npm ci # lockfile-exact install, never npm install
COPY --chown=node:node . . # then the source
###################
# LINT STAGE
###################
FROM base AS lint
RUN npm run lint:report 2>&1 > /dev/null || echo "lint report done"
###################
# UNIT TEST STAGE
###################
FROM base AS unittest-preparation
# credentials exist ONLY during this RUN — never in a layer
RUN --mount=type=secret,id=aws,dst=/root/.aws/credentials \
npm run test -- --json --outputFile results/unittest_report.json \
--coverage --coverageDirectory results
FROM scratch AS unittest # artifact stage: report only
COPY --from=unittest-preparation /usr/src/app/results .
###################
# E2E STAGE
###################
FROM base AS e2e
RUN apk add --update docker openrc && rc-update add docker boot
RUN npm run build
CMD ["npm", "run", "test:e2e"] # runs as a container (testcontainers)
###################
# AUDIT REPORT EXPORT STAGE
###################
FROM base AS audit-preparation
RUN npm audit --json --audit-level=moderate > npm_audit_report.json || true
FROM scratch AS audit # artifact stage: report only
COPY --from=audit-preparation /usr/src/app/npm_audit_report.json .
###################
# TEST COVERAGE REPORT EXPORT STAGE
###################
FROM scratch AS coverage # artifact stage: report only
COPY --from=base /usr/src/app/coverage/coverage-final.json .
###################
# BUILD FOR PRODUCTION
###################
FROM node:18.17.1-alpine AS build
WORKDIR /usr/src/app
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=base /usr/src/app/node_modules ./node_modules
COPY --chown=node:node . .
RUN npm run build # nest build needs dev deps (from base)
ENV NODE_ENV production
RUN npm ci --omit=dev && npm cache clean --force # prod-only node_modules
USER node # least privilege from here on
###################
# PRODUCTION
###################
FROM node:18-alpine AS production
WORKDIR /usr/src/app
RUN apk add --no-cache jq
COPY --chown=node:node --from=base /usr/src/app/get_env_vars.sh ./
COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/dist ./dist
CMD . get_env_vars.sh && node dist/src/main.js
| Stage | What it does | The control it evidences |
|---|---|---|
| base | Pinned node:18.17.1-alpine, lockfile-exact npm ci, source copied as node:node. Everything else derives from this one hermetic root. |
Supply chain: the same pinned toolchain and dependency set for every downstream check — a report is meaningless if each check built its own world. |
| lint | Runs the linter in report mode; the report lands in the stage filesystem. | Code-quality control runs on every change — the static analyzer ingests the report, not a human promise. |
| unittest-preparation → unittest | Jest with --json --outputFile + coverage into results/; AWS credentials mounted as a BuildKit secret, existing only for the duration of the RUN. The unittest stage is FROM scratch holding just results/. |
Change management: tests ran, results are machine-readable, and no credential ever became an image layer. |
| audit-preparation → audit | npm audit --json --audit-level=moderate into a report file; the audit stage exports it alone. |
Vulnerability management: a dependency scan per build, in the exact dependency tree that ships — feed for the vulnerability assessment platform. |
| coverage | FROM scratch exporting coverage-final.json. |
Test-coverage trend for the analytical systems — and the auditor's favorite chart. |
| e2e | Docker-in-docker (testcontainers) image; runs as a container via CMD, not as a build step. |
The full-system test evidence — separate because it needs a live database and Docker socket (Part 4 of the EAV series covers what it tests). |
| build → production | Prod bundle built with dev deps, then npm ci --omit=dev; final image copies only dist + prod node_modules and runs as USER node. |
Least privilege and minimal surface: no compilers, no dev deps, no root in the shipped image. |
The step diagram
One root, two kinds of leaves: solid boxes are runnable stages, dashed boxes are
FROM scratch artifact stages — images that contain only a report, ready
for CI to export.
The artifact trick
The load-bearing idea is the pairing of a preparation stage with a FROM scratch
export stage. A scratch image has no OS, no shell, no runtime — it is a tarball with a
manifest. Point a build at that target with --output and BuildKit writes the
stage's filesystem — i.e. just the report — into a local directory. The control ran
inside the hermetic build; the evidence stepped out of it.
CI: exporting the artifacts and shipping them onward
# one build graph, four targets — BuildKit runs shared stages once
SHA=$(git rev-parse --short HEAD)
docker build --target unittest --output type=local,dest=artifacts/unittest .
docker build --target coverage --output type=local,dest=artifacts/coverage .
docker build --target audit --output type=local,dest=artifacts/audit .
docker build --secret id=aws,src=$HOME/.aws/credentials \
--target production -t backoffice:$SHA .
# gate: CI decides, reading the artifacts — the build only produces them
jq -e '.metadata.vulnerabilities.high == 0 and
.metadata.vulnerabilities.critical == 0' \
artifacts/audit/npm_audit_report.json
# export to the analytical systems, keyed by commit
sonar-scanner -Dsonar.projectVersion=$SHA \
-Dsonar.javascript.lcov.reportPaths=artifacts/coverage/coverage-final.json
vulnscan-cli upload --sha $SHA artifacts/audit/npm_audit_report.json
# and to the immutable evidence store — this is what the auditor samples
aws s3 cp --recursive artifacts/ s3://audit-evidence/backoffice/$SHA/
| Artifact | Consumer | What the auditor gets |
|---|---|---|
| lint report | Static code analysis platform | Code-quality control executes on every change, with trend history. |
| unittest_report.json | CI gate + evidence store | Tests ran for commit X at time T, with per-suite results — sampled directly during the audit. |
| coverage-final.json | Static analysis platform | Coverage per commit; regressions visible the day they happen, not at year-end. |
| npm_audit_report.json | Vulnerability assessment platform | Dependency vulnerabilities per build with severity counts — plus the remediation clock SOC 2 cares about. |
| the production image itself | Registry (image scanning) | Pinned base, no dev dependencies, non-root user — the artifact is the hardening evidence. |
Caveats
All learned on this exact file — including two honest flaws it still carries.
- Record always, gate deliberately. The
|| trueand|| echosuffixes are intentional: a failing check must never prevent the evidence from existing. The gate lives in CI, reading the artifact (thejqline above) — which controls block a merge is a policy decision, versioned next to the pipeline, not an accident of exit codes. - Layer cache serves stale scans.
npm auditin a cached layer happily re-serves last month's report when the lockfile hasn't changed — but new CVEs arrive against old lockfiles daily. Bust the audit stage's cache every run (a build-arg date works), and re-scan the dependency tree on a schedule, not only on change. - Watch for stage drift. The
coveragestage exportscoverage/frombase— but the test run that once populated it there was later moved intounittest-preparation(which writes toresults/). The export kept working against an empty directory. Artifact stages need their own CI assertion: the artifact exists and is non-empty — an evidence pipeline that exports nothing looks exactly like one that works. - Pin all the way down.
baseandbuildpin18.17.1; theproductionstage saysnode:18-alpine— a floating tag in the one image that ships. Same-file inconsistencies like this are what an auditor (or an incident) finds. Pin by digest in the production stage, and let one renovate-style bot be the only thing that moves pins. - Secrets are mount-only — keep them that way. The BuildKit secret mount is the difference between "credentials existed during a RUN" and "credentials are in layer 7 forever". Nothing else in the file may
COPYorENVa credential, and that's a review rule, because it only takes one convenient shortcut. - The e2e stage is a container, not a build step. Testcontainers needs a Docker socket, which a build stage doesn't have — so e2e ships as an image and CI runs it. Its results join the same artifact flow, but its privileges (DinD) deserve their own scrutiny.
Before the audit: the decision checklist
- Gate vs record, per control. Which artifact findings block a merge, which only feed the trend? (my choice — critical/high vulnerabilities and test failures gate; lint and coverage record and trend, with ratchets over thresholds)
- Artifact format. Human reports or machine-readable? (my choice — JSON everywhere; humans get dashboards rendered by the analytical systems, never raw files)
- Evidence retention. Where do artifacts live and for how long? (my choice — immutable object storage keyed by commit SHA, write-once, retained past the audit period)
- Scan freshness. Per-commit only, or scheduled too? (my choice — per-commit plus nightly re-scan of the shipped lockfile; cache-busted so a scan is never a cached layer)
- Pinning policy. Tags, exact versions, or digests? (my choice — exact version tags everywhere, digest pins for the production stage, one bot owns every bump)
- Secret exposure surface. How do credentials reach builds? (my choice — BuildKit secret mounts only; an image-history scan in CI asserts no layer ever grew a secret)
- Artifact liveness. Who notices an exporter that exports nothing? (my choice — CI asserts every artifact is present and non-empty before shipping it onward; learned from the coverage drift above)
An audit control that produces no artifact is a promise. A build stage that exports one is a fact — timestamped, per commit, and impossible to forget to do.
What Part 2 would cover
The CI layer above this file: the pipeline that runs the targets, enforces the gates, signs and stores the artifacts, and the access-control story (who can merge, who can deploy, who can touch the evidence store) that makes up the rest of the SOC 2 picture.