CI for Containers & Monorepos
CI for Containers & Monorepos
Two architectural realities dominate modern big-tech CI: nearly every deployable unit is a container image, and many teams cohabit a single repository (monorepo). These two facts collide hard in CI — naive pipelines build and test everything on every push, burning hundreds of minutes of runner time per commit. This lesson covers how production teams handle both: building images efficiently inside CI and running only the affected targets when only a slice of the codebase changed.
Building Container Images in CI
A Dockerfile is deterministic source code. The CI pipeline is responsible for building it, tagging the result, pushing it to a registry, and making the digest available for deployment. The four non-negotiables for production image builds are:
- BuildKit is mandatory. Classic
docker buildis serial and has no remote cache API. Enable BuildKit by settingDOCKER_BUILDKIT=1or using thedocker/build-push-actionwhich enables it automatically. BuildKit parallelises stages, skips unreachable stages, and supports the--mount=type=cacheinstruction for dependency caches inside the build. - Layer order determines cache hit rate. Copy files that change rarely (lock files, static configs) early; copy application source last. A misplaced
COPY . .at step 3 of 10 invalidates the cache for every commit. - Multi-stage builds keep images minimal. A build stage with compilers, SDKs, and test tooling should not ship to production. The final
FROMstage should be a distroless or slim base that contains only the runtime binary and its dependencies. - Always push by digest, deploy by digest. Tags are mutable aliases. The CI pipeline should record the
sha256digest of the pushed image (available fromdocker/build-push-actionoutputs) and pass it to the deploy stage.
push: ${{ github.event_name != 'pull_request' }} as shown above. Building (and optionally scanning) the image on a PR gives you fast feedback without polluting the registry with thousands of unreviewed images. On merge to main, the image is built again from the same cache and pushed — the second build is essentially free because the layer cache is warm.
Path-Filtered Triggers
In a standard repository, pushing a README change should not rebuild and re-test the entire application. Path filters restrict which file changes trigger a workflow. GitHub Actions supports them natively via the paths and paths-ignore keys on push and pull_request events.
The pattern services/api/** uses glob syntax: ** matches any number of path segments including zero. Common patterns used in production:
src/**/*.ts— any TypeScript file anywhere undersrc/!docs/**— exclude changes indocs/(negate with!)packages/shared/**— changes to a shared library that many services depend on.github/workflows/api.yml— the workflow file itself; changing it should re-run the workflow
docs/ and the CI workflow is path-filtered to skip docs changes, the required check never runs — and GitHub treats a skipped check as not-yet-passed, blocking the merge. The fix: use a separate lightweight workflow for docs-only changes that always passes, or configure the required check with "skipped counts as success" in branch protection settings (available since GitHub Actions skippable status checks, 2024).
Affected-Target Builds in Monorepos
A monorepo hosts multiple services, libraries, and applications in one repository. Google's internal build system (Blaze, open-sourced as Bazel) pioneered the concept of affected-target builds: only build and test the transitive closure of targets that depend on changed files. This is the critical insight that lets Google run CI over millions of lines with tens of thousands of targets while keeping per-commit feedback under 10 minutes.
The four dominant tools for affected-target analysis in open-source monorepos are:
- Nx (JavaScript/TypeScript, but supports any language via plugins) —
nx affected --target=buildcomputes the affected graph using a dependency graph and commit range. - Turborepo (JavaScript/TypeScript) —
turbo run build --filter=[HEAD^1]runs only affected packages using content-hash caching. - Bazel (polyglot) —
bazel build $(bazel query 'rdeps(//..., set($(git diff --name-only HEAD^)))')uses the full dependency graph to find reverse dependents of changed files. - Pants (Python, Java, Go, Scala) —
pants --changed-since=origin/main testruns the same affected-target logic with a Pants-specific query engine.
Nx Affected in GitHub Actions
The most common JavaScript/TypeScript monorepo setup uses Nx. The key is computing the base commit (BASE) that represents the last known-good state — typically the base branch for PRs, or the previous commit for pushes to main.
fetch-depth: 0 is non-negotiable in monorepos. Affected-target tools diff the current HEAD against the base branch commit. Without full history, git diff has nothing to compare against and the tool either errors out or falls back to rebuilding everything — defeating the entire purpose. Always set fetch-depth: 0 on the checkout step in monorepo workflows.
Per-Service Dockerfiles in a Monorepo
Each service in a monorepo has its own Dockerfile, but the Docker build context must include shared library code that lives outside the service directory. The solution is to always set the build context to the repository root and reference the Dockerfile by path:
docker build services/api/ with the service directory as the context, Docker cannot access libs/shared-utils/ — it lives outside the context. The Dockerfile COPY libs/shared-utils ... instruction fails with "path not found". Always build from the repo root. For large monorepos, use .dockerignore aggressively to exclude other services and their node_modules from the context — otherwise the context sent to the BuildKit daemon can be gigabytes.
Remote Caching for Monorepos
Affected-target builds solve which targets to run. Remote caching solves whether to run them at all. If target X was built from source hash H and its result is already in the remote cache, skip the execution entirely and restore the output. This is how Google achieves near-zero incremental build times: the vast majority of targets hit the cache on every CI run.
- Nx Cloud — managed remote cache for Nx workspaces. Connect with
nx connectand addNX_CLOUD_ACCESS_TOKENto secrets. Cache hit rates of 80-95% are typical for active teams. - Turborepo Remote Cache — built into
turbo runwith--apiand--tokenflags pointing to a Vercel or self-hosted cache server. - Bazel Remote Cache — any gRPC/HTTP endpoint; Google's open-source
bazel-remoteproject works on GCS or S3 backends.
Common Failure Modes
- Shallow clone in affected builds —
fetch-depth: 1(the GitHub Actions default) meansgit diffsees no history; the tool rebuilds everything. Setfetch-depth: 0. - Missing
.dockerignore— sending a 2 GB monorepo as the Docker build context adds 30–90 seconds to every image build. Maintain a root-level.dockerignorethat excludes.git, test fixtures, and other services. - Pushing on PRs — registry fills with unreviewed images from every PR commit. Gate the
push: trueongithub.event_name != 'pull_request'. - Implicit latest tag in production — deploying
:latestmakes rollbacks unreliable and makes it impossible to know what is running without inspecting the live container. Always deploy by the immutable digest or commit-SHA tag. - Sharing a single workflow file for all services — one YAML file with a matrix over all services builds everything on every push, negating the benefit of affected builds. Give each service (or service group) its own workflow file with appropriate path filters.