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
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
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
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 test
Key: Makefile.container is mounted over Makefile inside the container.
Container Makefile
Runs commands directly:
# Makefile.container (becomes Makefile inside container)
.PHONY: build test lint
build:
cargo build
test:
cargo test
lint:
cargo clippy
User Experience
# On host
$ make build
# → Starts container, mounts overlay, runs `make build` inside
# Inside container
$ make build
# → Runs cargo build directly
Same command. Same interface. Context determines implementation.
Advantages
No Conditionals
Common pattern (cluttered):
IN_DOCKER := $(shell test -f /.dockerenv && echo 1 || echo 0)
ifeq ($(IN_DOCKER),1)
build:
cargo build
else
build:
docker run ... make build
endif
Overlay pattern (clean):
# Host Makefile: delegates
build:
docker run -v ./Makefile.container:/workspace/Makefile:ro ... make build
# Container Makefile: executes
build:
cargo build
Single Interface
No need to remember:
make buildvsmake docker-buildmake testvsmake container-test- Which file to edit for which context
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
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 test
Optional: Escape Hatch for Nested Containers
When running inside an IDE devcontainer, you may want to skip container nesting:
ifdef DEVCONTAINER
DOCKER_RUN :=
else
DOCKER_RUN := docker run --rm -v ... myimage
endif
build:
$(DOCKER_RUN) make -f Makefile.container build
This is optional—only needed if your workflow involves pre-existing container environments.
Using with just
The same pattern works with just—and the code is cleaner:
Host filesystem:
├── justfile # Delegates to container
└── justfile.container # Runs commands directly
Host 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 test
Container justfile:
build:
cargo build
test:
cargo test
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
| 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
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
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 spotlessApply
How 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-python
Same command, same results, no local tool installation required.
Working Example
A minimal working example demonstrating this pattern:
cd docs/examples/container-overlay
make demo
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
- GNU Make Manual — Make documentation
- just — Modern command runner alternative
- just in Angzarr — Project-specific just commands