Andrei Vogulkin
← All writing
Research notes

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
StageWhat it doesThe 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.

base node:18.17.1-alpine (pinned) npm ci + source secrets: mount-only lint eslint → report unittest-preparation jest --json + coverage audit-preparation npm audit --json e2e DinD + testcontainers build dist + prod deps only coverage coverage-final.json unittest unittest_report.json audit npm_audit_report.json production dist · USER node dashed = FROM scratch, artifact only CI: docker build --target X --output → static analysis · vuln assessment

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/
ArtifactConsumerWhat 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 || true and || echo suffixes are intentional: a failing check must never prevent the evidence from existing. The gate lives in CI, reading the artifact (the jq line 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 audit in 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 coverage stage exports coverage/ from base — but the test run that once populated it there was later moved into unittest-preparation (which writes to results/). 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. base and build pin 18.17.1; the production stage says node: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 COPY or ENV a 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.

← Back to all writing Work with me →