Skip to content

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.


Containerized builds ensure consistent environments, but create friction:

ApproachDrawback
Dual files (Makefile, Makefile.docker)Must know which to invoke
Conditional detection (ifeq ($(IN_DOCKER),1))Clutters Makefile with branches
Different commands per contextCognitive overhead, documentation burden

Ideally: same command, same interface, different implementation based on context.


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.


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.

Runs commands directly:

# Makefile.container (becomes Makefile inside container)
.PHONY: build test lint
build:
cargo build
test:
cargo test
lint:
cargo clippy
Terminal window
# 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.


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

No need to remember:

  • make build vs make docker-build
  • make test vs make container-test
  • Which file to edit for which context
  • Host concerns (container orchestration, mounts, networking) stay in host Makefile
  • Build concerns (compilation, testing, linting) stay in container Makefile
  • No mixing of responsibilities

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

Section titled “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.


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
Makejust
Requires .PHONY declarationsNo declarations needed
$$(hostname) escaping$(hostname) works naturally
Verbose variable syntaxClean argument passing ({{ARGS}})
gnumake vs BSD make differencesCross-platform consistency

just’s module system also composes naturally with this pattern—module commands route through the container transparently.


PatternDetectionFilesConditionals
Dual MakefilesUser chooses file2 separate interfacesNone
/.dockerenv checkRuntime file test1Yes
Env var check$IN_DOCKER1Yes
Mount overlayMount replaces file2 files, 1 interfaceNone

The mount overlay pattern uses two files but presents a single interface. Detection is implicit in the mount configuration, not explicit in code.


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

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:

  1. _lang-container mounts the repo at /workspace
  2. Formatters run inside the container against mounted files
  3. Changes persist to host filesystem via the volume mount
  4. DEVCONTAINER check skips container nesting when already inside a devcontainer

User experience:

Terminal window
# 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.


A minimal working example demonstrating this pattern:

Terminal window
cd docs/examples/container-overlay
make demo
  1. Create host Makefile with container delegation
  2. Create Makefile.container with direct commands
  3. Configure container to mount overlay: -v ./Makefile.container:/workspace/Makefile:ro
  4. Ensure same target names in both files
  5. Test: make <target> should work identically in both contexts