Container Overlay Pattern for Makefiles
A technique for running the same make commands on host and inside containers without conditionals or duplicate interfaces.
Works with GNU Make, just, or any file-based command runner.
The Problem
Section titled “The Problem”Containerized builds ensure consistent environments, but create friction:
| Approach | Drawback |
|---|---|
Dual files (Makefile, Makefile.docker) | Must know which to invoke |
Conditional detection (ifeq ($(IN_DOCKER),1)) | Clutters Makefile with branches |
| Different commands per context | Cognitive overhead, documentation burden |
Ideally: same command, same interface, different implementation based on context.
The Pattern
Section titled “The Pattern”Mount a container-specific Makefile over the host Makefile inside the container.
Host filesystem:├── Makefile # Delegates to container└── Makefile.container # Runs commands directly
Inside container (after mount):└── Makefile # IS Makefile.container (mounted over)The file swap is the detection mechanism. No conditionals needed.
How It Works
Section titled “How It Works”Host Makefile
Section titled “Host Makefile”Delegates all work to container execution:
# Makefile (host version).PHONY: build test lint
build: docker run --rm \ -v ./:/workspace \ -v ./Makefile.container:/workspace/Makefile:ro \ -w /workspace \ myimage make build
test: docker run --rm \ -v ./:/workspace \ -v ./Makefile.container:/workspace/Makefile:ro \ -w /workspace \ myimage make testKey: Makefile.container is mounted over Makefile inside the container.
Container Makefile
Section titled “Container Makefile”Runs commands directly:
# Makefile.container (becomes Makefile inside container).PHONY: build test lint
build: cargo build
test: cargo test
lint: cargo clippyUser Experience
Section titled “User Experience”# On host$ make build# → Starts container, mounts overlay, runs `make build` inside
# Inside container$ make build# → Runs cargo build directlySame command. Same interface. Context determines implementation.
Advantages
Section titled “Advantages”No Conditionals
Section titled “No Conditionals”Common pattern (cluttered):
IN_DOCKER := $(shell test -f /.dockerenv && echo 1 || echo 0)ifeq ($(IN_DOCKER),1)build: cargo buildelsebuild: docker run ... make buildendifOverlay pattern (clean):
# Host Makefile: delegatesbuild: docker run -v ./Makefile.container:/workspace/Makefile:ro ... make build
# Container Makefile: executesbuild: cargo buildSingle Interface
Section titled “Single Interface”No need to remember:
make buildvsmake docker-buildmake testvsmake container-test- Which file to edit for which context
Clear Separation of Concerns
Section titled “Clear Separation of Concerns”- Host concerns (container orchestration, mounts, networking) stay in host Makefile
- Build concerns (compilation, testing, linting) stay in container Makefile
- No mixing of responsibilities
DRY Container Configuration
Section titled “DRY Container Configuration”Extract container invocation to a variable:
DOCKER_RUN := docker run --rm \ -v ./:/workspace \ -v ./Makefile.container:/workspace/Makefile:ro \ -w /workspace \ myimage
build: $(DOCKER_RUN) make build
test: $(DOCKER_RUN) make testOptional: Escape Hatch for Nested Containers
Section titled “Optional: Escape Hatch for Nested Containers”When running inside an IDE devcontainer, you may want to skip container nesting:
ifdef DEVCONTAINERDOCKER_RUN :=elseDOCKER_RUN := docker run --rm -v ... myimageendif
build: $(DOCKER_RUN) make -f Makefile.container buildThis is optional—only needed if your workflow involves pre-existing container environments.
Using with just
Section titled “Using with just”The same pattern works with just—and the code is cleaner:
Host filesystem:├── justfile # Delegates to container└── justfile.container # Runs commands directlyHost justfile:
_run +ARGS: podman run --rm \ -v ./:/workspace:Z \ -v ./justfile.container:/workspace/justfile:ro \ -w /workspace \ myimage just {{ARGS}}
build: just _run build
test: just _run testContainer justfile:
build: cargo build
test: cargo testWhy just is cleaner
Section titled “Why just is cleaner”| Make | just |
|---|---|
Requires .PHONY declarations | No declarations needed |
$$(hostname) escaping | $(hostname) works naturally |
| Verbose variable syntax | Clean argument passing ({{ARGS}}) |
| gnumake vs BSD make differences | Cross-platform consistency |
just’s module system also composes naturally with this pattern—module commands route through the container transparently.
Comparison with Prior Art
Section titled “Comparison with Prior Art”| Pattern | Detection | Files | Conditionals |
|---|---|---|---|
| Dual Makefiles | User chooses file | 2 separate interfaces | None |
/.dockerenv check | Runtime file test | 1 | Yes |
| Env var check | $IN_DOCKER | 1 | Yes |
| Mount overlay | Mount replaces file | 2 files, 1 interface | None |
The mount overlay pattern uses two files but presents a single interface. Detection is implicit in the mount configuration, not explicit in code.
When to Use
Section titled “When to Use”Good fit:
- Containerized CI/CD with local development parity
- Teams with mixed host environments (Linux, macOS, WSL)
- Projects where build tooling differs from runtime
- Polyglot projects with language-specific containers
Not needed:
- Simple projects without containerized builds
- When all developers use identical environments
- Single-platform deployments
Real Example: Angzarr Formatting
Section titled “Real Example: Angzarr Formatting”Angzarr uses this pattern for cross-language code formatting. The host justfile runs formatters inside language-specific containers with the workspace mounted:
Host justfile (justfile):
# Run command in language-specific CI image[private]_lang-container LANG +ARGS: #!/usr/bin/env bash if [ "${DEVCONTAINER:-}" = "true" ]; then {{ARGS}} else podman run --rm --network=host \ -v "$(git rev-parse --show-toplevel):/workspace:Z" \ -w /workspace \ ghcr.io/angzarr-io/angzarr-{{LANG}}:latest \ {{ARGS}} fi
# Format Python code (runs in angzarr-python container)fmt-python: just _lang-container python black examples/python client/python scripts/ just _lang-container python ruff check --fix --select I examples/python client/python scripts/
# Format Go code (runs in angzarr-go container)fmt-go: just _lang-container go goimports -w examples/go client/go
# Format C# code (runs in angzarr-csharp container)fmt-csharp: just _lang-container csharp csharpier format examples/csharp client/csharp
# Format Java code (runs in angzarr-java container)fmt-java: just _lang-container java ./examples/java/gradlew -p examples/java spotlessApplyHow it works:
_lang-containermounts the repo at/workspace- Formatters run inside the container against mounted files
- Changes persist to host filesystem via the volume mount
DEVCONTAINERcheck skips container nesting when already inside a devcontainer
User experience:
# On host - runs black inside angzarr-python container$ just fmt-python
# Inside devcontainer - runs black directly (skips container)$ just fmt-pythonSame command, same results, no local tool installation required.
Working Example
Section titled “Working Example”A minimal working example demonstrating this pattern:
cd docs/examples/container-overlaymake demoImplementation Checklist
Section titled “Implementation Checklist”- Create host
Makefilewith container delegation - Create
Makefile.containerwith direct commands - Configure container to mount overlay:
-v ./Makefile.container:/workspace/Makefile:ro - Ensure same target names in both files
- Test:
make <target>should work identically in both contexts
Next Steps
Section titled “Next Steps”- GNU Make Manual — Make documentation
- just — Modern command runner alternative
- just in Angzarr — Project-specific just commands