Make as a Deployment Compiler
GNU Make is often treated as a task runner, but it can also be a compact templating engine and orchestration layer. That becomes useful when a team needs repeatable generated artifacts, environment-specific configuration, validation, diffing, and deployment commands without introducing a full application or framework.
Make is not more elegant as a configuration language. Jsonnet, Dhall, Helm, and CUE have cleaner expression models, stronger typing, or domain-native templating. Make wins because large deployments are not only configuration problems. They are ordered workflows: select env, validate deltas, render files, call Helm, inspect cluster state, run smoke tests, capture evidence, and clean up. Make puts that rollout graph behind one ordinary command.
The Shape
A useful Make deployment compiler has five moving parts:
- parameter files
- parse-time validation
- multiline
definetemplates - a manifest registry
- artifact and E2E targets
The flow looks like this:
env/defaults.param
env/prod.param
|
v
Make parse + validation
|
v
define/endef YAML templates
|
v
build/prod/manifest.yaml
|
v
diff, lint, apply, smoke, cleanup
The important property is that every target consumes the same rendered stream.
render, diff, apply, and e2e do not drift apart.
Think of Make as a Render Graph
A useful mental model is:
parameter files + defaults + validation + templates → generated artifacts → checks/actions
In a generic service-deployment example:
ENV ?= dev
ALLOWED_ENVS := global dev sit uat prod
ifeq ($(filter $(ENV),$(ALLOWED_ENVS)),)
$(error Invalid ENV value '$(ENV)'. Allowed values are: $(ALLOWED_ENVS))
endif
PARAM_FILE := $(ENV).param
ifeq ($(wildcard $(PARAM_FILE)),)
$(error Parameter file for environment '$(ENV)' not found: $(PARAM_FILE))
endif
include $(PARAM_FILE)
include global.param
This gives Make a clean configuration surface:
make template ENV=dev
make apply ENV=prod
make diff-environments
Environment Selection
Start with a boring environment contract:
ENV ?= dev
ALLOWED_ENVS := dev stage prod
PARAM_FILE := env/$(ENV).param
ifeq ($(filter $(ENV),$(ALLOWED_ENVS)),)
$(error Invalid ENV: $(ENV))
endif
ifeq ($(wildcard $(PARAM_FILE)),)
$(error Missing param file: $(PARAM_FILE))
endif
include env/defaults.param
include $(PARAM_FILE)
Operators get one interface:
make render ENV=stage
make diff ENV=prod
make e2e ENV=lab
The win is timing. Make reads includes and evaluates conditionals before recipes run. Bad environments and missing files fail before Helm or Kubernetes is invoked.
Validate Before Rendering
Make is not a schema language, but it is good enough for fast policy checks:
ifneq ($(filter true false,$(HPA_ENABLED)),$(HPA_ENABLED))
$(error HPA_ENABLED must be true or false)
endif
REPLICA_OK := $(shell printf '%s\n' "$(REPLICAS)" | \
grep -Eq '^[0-9]+$$' && echo ok || echo bad)
ifeq ($(REPLICA_OK),bad)
$(error REPLICAS must be a non-negative integer)
endif
ifneq ($(findstring m,$(CPU_REQUEST)),m)
$(error CPU_REQUEST must use millicores)
endif
This stops obvious deployment mistakes at the cheapest possible point.
Template Technique
Make stores multiline YAML with define and endef. Exported variables can
then be printed as complete YAML documents.
define configmap_yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
name: $(APP_NAME)-config
namespace: $(NAMESPACE)
data:
LOG_LEVEL: "$(LOG_LEVEL)"
SPRING_PROFILES_ACTIVE: "$(SPRING_PROFILES)"
endef
export configmap_yaml
The registry is just an array:
manifests += $${configmap_yaml}
manifests += $${deployment_yaml}
manifests += $${service_yaml}
.PHONY: template
template:
@$(foreach manifest,$(manifests),\
printf '%s\n' "$(manifest)";)
That list is the deployment graph. Add or remove a resource by changing the registry, not five separate targets.
Apply, Delete, and Validate Through STDIN
Send rendered YAML directly to kubectl:
.PHONY: apply
apply: validate-client
@$(MAKE) --no-print-directory template ENV=$(ENV) | kubectl apply -f -
.PHONY: delete
delete:
@$(MAKE) --no-print-directory template ENV=$(ENV) | kubectl delete --ignore-not-found -f -
.PHONY: validate-yaml
validate-yaml:
@$(MAKE) --no-print-directory template ENV=$(ENV) | yq eval '.' >/dev/null
.PHONY: validate-client
validate-client: validate-yaml
@$(MAKE) --no-print-directory template ENV=$(ENV) | kubectl apply --dry-run=client -f -
.PHONY: validate-server
validate-server: validate-yaml
@$(MAKE) --no-print-directory template ENV=$(ENV) | kubectl apply --dry-run=server -f -
vLLM YAML
Here is the same pattern applied to vLLM.
define vllm_configmap
---
apiVersion: v1
kind: ConfigMap
metadata:
name: ${VLLM_NAME}-config
namespace: ${NAMESPACE}
labels:
app.kubernetes.io/name: ${VLLM_NAME}
environment: ${ENV}
data:
model: "${MODEL_ID}"
served-model-name: "${SERVED_MODEL_NAME}"
engine-args.yaml: |-
host: 0.0.0.0
port: 8000
dtype: "${MODEL_DTYPE}"
max_model_len: ${MAX_MODEL_LEN}
tensor_parallel_size: ${TENSOR_PARALLEL_SIZE}
gpu_memory_utilization: ${GPU_MEMORY_UTIL}
endef
export vllm_configmap
define vllm_service
---
apiVersion: v1
kind: Service
metadata:
name: ${VLLM_NAME}
namespace: ${NAMESPACE}
labels:
app.kubernetes.io/name: ${VLLM_NAME}
app.kubernetes.io/component: engine
environment: ${ENV}
spec:
type: ClusterIP
ports:
- name: http
port: 8000
targetPort: http
selector:
app.kubernetes.io/name: ${VLLM_NAME}
app.kubernetes.io/component: engine
endef
export vllm_service
The deployment template carries the operational choices: model, image, runtime class, resources, and GPU count.
define vllm_deployment
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${VLLM_NAME}
namespace: ${NAMESPACE}
spec:
replicas: ${REPLICAS}
selector:
matchLabels:
app.kubernetes.io/name: ${VLLM_NAME}
app.kubernetes.io/component: engine
template:
metadata:
labels:
app.kubernetes.io/name: ${VLLM_NAME}
app.kubernetes.io/component: engine
environment: ${ENV}
spec:
runtimeClassName: ${RUNTIME_CLASS}
containers:
- name: vllm
image: ${VLLM_IMAGE}
command:
- python
- -m
- vllm.entrypoints.openai.api_server
args:
- --model
- "${MODEL_ID}"
- --served-model-name
- "${SERVED_MODEL_NAME}"
- --host
- "0.0.0.0"
- --port
- "8000"
- --tensor-parallel-size
- "${TENSOR_PARALLEL_SIZE}"
- --gpu-memory-utilization
- "${GPU_MEMORY_UTIL}"
- --max-model-len
- "${MAX_MODEL_LEN}"
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: VLLM_API_ADDR
value: "http://$$(POD_IP):8000"
ports:
- name: http
containerPort: 8000
readinessProbe:
httpGet:
path: /health
port: http
resources:
requests:
cpu: ${CPU_REQUEST}
memory: ${MEMORY_REQUEST}
nvidia.com/gpu: ${GPU_COUNT}
limits:
cpu: ${CPU_LIMIT}
memory: ${MEMORY_LIMIT}
nvidia.com/gpu: ${GPU_COUNT}
endef
export vllm_deployment
Now choose the rendered graph:
manifests += $${vllm_configmap}
manifests += $${vllm_service}
manifests += $${vllm_deployment}
ifeq (${VLLM_ENABLE_HPA},true)
manifests += $${vllm_hpa}
endif
The define blocks are the templates. The .param files are the environment
deltas. The registry is the graph.
Git Metadata Injection
Extract branch, commit, remote URL, and build timestamp into labels.
GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_REPO_URL := $(shell git config --get remote.origin.url 2>/dev/null || echo local)
BUILD_TIMESTAMP := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
define metadata_labels_yaml
app.kubernetes.io/name: ${APP_NAME}
app.kubernetes.io/version: ${IMAGE_TAG}
app.example.com/env: ${ENV}
app.example.com/git-branch: ${GIT_BRANCH}
app.example.com/git-commit: ${GIT_COMMIT}
app.example.com/repo: ${GIT_REPO_URL}
app.example.com/build-timestamp: ${BUILD_TIMESTAMP}
endef
export metadata_labels_yaml
Use Pattern Targets for Inspection
Debuggability is where Make-based templating can be surprisingly strong. The example Makefile exposes generic inspection targets:
print-%:
@echo "$$*"
list-vars:
@echo "Variable Name Origin"
@echo "------------------------------"
@$(foreach var, $(filter-out .% %_FILES, $(.VARIABLES)), \
$(if $(filter-out default automatic, $(origin $(var))), \
printf "%-20s %s\n" "$(var)" "$(origin $(var))"; \
))
Generalized forms are useful in most templating Makefiles:
print-%:
@printf '%s\n' "$($*)"
origin-%:
@printf '%s %s\n' "$*" "$(origin $*)"
$(origin VAR) is especially useful when environment variables, command-line overrides,
included .param files, and defaults can all define the same setting.
Treat Generated Output as a First-Class Artifact
Do not wire every target directly to deployment. Add targets that materialize and validate the rendered output:
convert-to-json:
@$(foreach manifest,$(manifests),echo "$(manifest)" | yq eval -o=json -P '.' -;)
dump-manifests: template convert-to-json
@echo "Dumping manifests to manifest.yaml and manifest.json…"
@make template > manifest.yaml
@make convert-to-json > manifest.json
Validate at Parse Time
Make conditionals and $(error …) are enough for many policy checks:
ifneq ($(findstring m,$(CPU_REQUEST)),m)
$(error Invalid CPU_REQUEST value '$(CPU_REQUEST)'. It must end with 'm'.)
endif
CPU_REQUEST_VALUE := $(subst m,,$(CPU_REQUEST))
ifeq ($(shell [ $(CPU_REQUEST_VALUE) -ge 100 ] && [ $(CPU_REQUEST_VALUE) -le 2000 ] && echo true),)
$(error Invalid CPU_REQUEST value '$(CPU_REQUEST)'. It must be between 100m and 2000m.)
endif
This is better than burying validation inside a deploy recipe because make template,
make validate-client, and make apply all share the same gate. The render
graph cannot even be constructed with invalid input.
For larger projects, keep parse-time validation fast and deterministic. Shelling out with
$(shell …) is acceptable for small numeric checks, but expensive checks should move to
explicit targets such as validate, validate-client, or
validate-server.
Escaping Is the Sharp Edge
Make expands variables before the shell runs. Kubernetes, Helm, Spring Boot, and shell scripts may also have their own variable syntax.
Use $$ when a value must survive Make expansion:
- name: VLLM_API_ADDR
value: "http://$$(POD_IP):8000"
Rendered YAML contains:
- name: VLLM_API_ADDR
value: "http://$(POD_IP):8000"
Rules of thumb:
$(NAME)— Make variable expansion${NAME}— Make expansion, shell-compatible form$$NAME— literal shell variable inside a recipe$$(NAME)— literal$(NAME)after Make expansion
This is why debug targets matter. Most bugs in this style are not Kubernetes bugs. They are expansion-time bugs.
Write Artifacts First
Do not deploy straight from templates. Materialize the rendered output:
BUILD_DIR ?= build/$(ENV)
MANIFEST_FILE := $(BUILD_DIR)/manifest.yaml
$(BUILD_DIR):
@mkdir -p $@
$(MANIFEST_FILE): Makefile env/defaults.param \
env/$(ENV).param | $(BUILD_DIR)
@$(MAKE) --no-print-directory template ENV=$(ENV) > $@
.PHONY: render
render: $(MANIFEST_FILE)
Now review the same YAML that deployment will use:
make render ENV=prod
kubectl diff -f build/prod/manifest.yaml
kubectl apply --dry-run=server \
-f build/prod/manifest.yaml
This makes generated YAML a first-class artifact, not an invisible side effect.
Debug the Compiler
Advanced Makefiles need inspection targets. They should answer four questions:
- what value will this variable have?
- where did that value come from?
- is it recursive or simply expanded?
- what will be rendered or executed?
Use print-% for the expanded value:
.PHONY: print-%
print-%:
@printf '%s\n' "$($*)"
Use origin-% for provenance:
.PHONY: origin-%
origin-%:
@printf '%s: %s\n' "$*" "$(origin $*)"
Use raw-% to inspect a define block before nested variables expand:
.PHONY: raw-%
raw-%:
@printf '%s\n' '$(value $*)'
And combine the useful views:
.PHONY: why-%
why-%:
@printf 'value: %s\n' "$($*)"
@printf 'origin: %s\n' "$(origin $*)"
@printf 'flavor: %s\n' "$(flavor $*)"
Examples:
make why-IMAGE_TAG ENV=lab IMAGE_TAG=debug
make raw-vllm_deployment ENV=lab
make -n install ENV=lab
make --debug=b -n render ENV=lab
make e2e ENV=lab TRACE=true
On GNU Make 4, gmake --trace render ENV=lab is also useful. Older macOS Make supports
--debug=b.
Canary-Style Manifest Mutation
The project derives canary resources from the same manifest list by changing replicas, instance names, service names, and track labels. A generalized version:
CANARY_REPLICAS ?= 1
CANARY_INSTANCE ?= $(APP_NAME)-canary
CANARY_SERVICE ?= $(APP_NAME)-canary
CANARY_TRACK ?= canary
STABLE_TRACK ?= stable
.PHONY: canary-template
canary-template:
@$(MAKE) --no-print-directory template ENV=$(ENV) | \
sed 's/replicas: $(REPLICAS)/replicas: $(CANARY_REPLICAS)/' | \
sed 's/app.kubernetes.io\/instance: $(APP_NAME)/app.kubernetes.io\/instance: $(CANARY_INSTANCE)/g' | \
sed 's/app.kubernetes.io\/track: $(STABLE_TRACK)/app.kubernetes.io\/track: $(CANARY_TRACK)/g' | \
sed 's/name: $(APP_NAME)-service/name: $(CANARY_SERVICE)/g'
.PHONY: canary-apply
canary-apply:
@$(MAKE) --no-print-directory canary-template ENV=$(ENV) | kubectl apply -f -
This is powerful because stable and canary are derived from one source of truth. The risk is that
sed is text-based. It is acceptable for constrained, well-known YAML, but for complex
mutations use yq:
.PHONY: canary-template-yq
canary-template-yq:
@$(MAKE) --no-print-directory template ENV=$(ENV) | \
CANARY_REPLICAS=$(CANARY_REPLICAS) \
yq eval 'with(select(.kind == "Deployment"); .spec.replicas = env(CANARY_REPLICAS))' -
For multiple documents and mixed resource kinds, a small Python script with a YAML parser is often
clearer than dense yq.
When Helm Is Already There
For a real Helm chart, do not rewrite all templates in Make. Use Make as the wrapper around Helm.
For the vLLM Production Stack chart, Make generates a values overlay, runs Helm lint and render, packages the chart, installs a smoke release, and captures evidence.
Example generated overlay:
servingEngineSpec:
enableEngine: false
routerSpec:
enableRouter: false
cacheserverSpec:
enabled: false
extraObjects:
- apiVersion: v1
kind: ConfigMap
metadata:
name: "{{ .Release.Name }}-make-smoke"
data:
environment: "lab"
Example targets:
lint: deps values
helm lint . -f build/$(ENV)/values.yaml
template: values
helm template $(RELEASE) . \
--namespace $(NAMESPACE) \
-f build/$(ENV)/values.yaml \
> build/$(ENV)/manifest.yaml
e2e: lint template package
helm upgrade --install $(RELEASE) \
build/$(ENV)/deploy-chart \
--namespace $(NAMESPACE) \
--create-namespace --wait \
-f build/$(ENV)/values.yaml
One real issue showed up during testing: disabled chart dependencies can still bloat Helm release
metadata when archives are present under charts/. The smoke install can use a temporary
chart that removes those archives:
$(DEPLOY_CHART_DIR): Chart.yaml values.yaml $(VALUES_FILE)
rm -rf $@
mkdir -p $@
tar --exclude './build' \
--exclude './charts' -cf - . | \
tar -C $@ -xf -
That policy does not belong inside the chart. It belongs in the deployment workflow around the chart.
Composing vLLM, Vault, and Istio
The combined showcase uses three surfaces:
- vLLM chart with smoke values
- Vault chart as a dev dependency service
- Istio Gateway and routing YAML
Make owns the cross-chart flow:
e2e: lint install
@cleanup() { \
$(MAKE) --no-print-directory uninstall || true; \
}; \
trap cleanup EXIT; \
$(MAKE) --no-print-directory smoke
The Istio route is just another generated artifact:
http:
- match:
- uri:
prefix: /vllm
route:
- destination:
host: vllm.showcase.svc.cluster.local
port:
number: 80
- match:
- uri:
prefix: /vault
route:
- destination:
host: vault.showcase.svc.cluster.local
port:
number: 8200
The tested flow linted both charts, installed both releases, applied Istio routing, waited for
Vault readiness, checked /vllm and /vault, captured evidence, and cleaned
the namespace.
Spring Boot Looks the Same
Spring Boot services are rarely just a Deployment and a Service. Production usually adds migrations, probes, JVM tuning, HPA, PDB, NetworkPolicy, ServiceMonitor, and Istio traffic rules.
The registry keeps that complexity explicit:
manifests += $${namespace_yaml}
manifests += $${service_account_yaml}
manifests += $${config_yaml}
manifests += $${secret_yaml}
ifeq ($(MIGRATION_ENABLED),true)
manifests += $${migration_yaml}
endif
manifests += $${deployment_yaml}
manifests += $${service_yaml}
manifests += $${pdb_yaml}
ifeq ($(HPA_ENABLED),true)
manifests += $${hpa_yaml}
endif
ifeq ($(ISTIO_ENABLED),true)
manifests += $${istio_yaml}
endif
Local can render only the small graph. Prod can render the full platform graph. Same Makefile,
different .param file.
Cluster-Aware Promotion
Make can inspect the cluster before rendering:
ISTIO_CRD := virtualservices.networking.istio.io
PROM_CRD := servicemonitors.monitoring.coreos.com
HAS_ISTIO := $(shell $(KUBECTL) get crd $(ISTIO_CRD) \
>/dev/null 2>&1 && echo true || echo false)
HAS_PROM := $(shell $(KUBECTL) get crd $(PROM_CRD) \
>/dev/null 2>&1 && echo true || echo false)
Then it can render capability-aware artifacts and run a progressive rollout:
promote: deploy-canary
@for weight in 5 25 50 100; do \
$(MAKE) set-weight WEIGHT=$$weight; \
$(MAKE) smoke || { \
$(MAKE) rollback; \
exit 1; \
}; \
done
$(KUBECTL) rollout status deploy/$(APP)
This is where Make does something Helm templates should not do. It can inspect the live system, coordinate several tools, make a decision, run checks, and record evidence.
Metaprogramming at Scale
In a monorepo with dozens of microservices and multiple environments, manually maintaining targets
is a recipe for drift. Use $(eval) to dynamically generate the Makefile's target graph
at parse-time.
SERVICES := auth billing gateway worker
ENVS := dev uat prod
# The "template" for a deploy target
define DEPLOY_RULE
deploy-$(1)-$(2):
@echo "--- Deploying $(1) to $(2) ---"
@$(MAKE) -s apply SERVICE=$(1) ENV=$(2)
endef
# Metaprogramming: generate the cross-product of targets
$(foreach svc,$(SERVICES), \
$(foreach env,$(ENVS), \
$(eval $(call DEPLOY_RULE,$(svc),$(env))) \
) \
)
# Result: 'make deploy-auth-prod' is automatically registered.
Parallel Multi-Region Orchestration
Make's native job server (-j) is its most underutilized feature in DevOps. By treating
regions or clusters as file-like targets, you can execute a global rollout as a directed acyclic
graph (DAG).
REGIONS := us-east-1 us-west-2 eu-central-1 ap-northeast-1
.PHONY: rollout-global
rollout-global: $(addprefix rollout-,$(REGIONS))
rollout-%:
@echo "Starting parallel rollout to $*…"
@$(MAKE) -s apply REGION=$* ENV=prod
# Execution: make -j 4 rollout-global
# Make handles the process pool, resource locking, and log interleaving.
Polyglot Logic Embedding
Make's string manipulation is notoriously brittle for structural logic (e.g., calculating GPU
memory partitions). Instead of writing complex sed or awk hacks, embed
Python or Node.js directly within a define block.
define CALC_VRAM_PY
import sys
total, fraction = map(float, sys.argv[1:])
print(int(total * fraction))
endef
export CALC_VRAM_PY
# Pull the calculated value into a Make variable
LIMIT_MB := $(shell python3 -c "$$CALC_VRAM_PY" $(GPU_VRAM_TOTAL) 0.85)
define deployment_yaml
# …
resources:
limits:
memory: "$(LIMIT_MB)Mi"
# …
endef
Dependency-Driven Secrets
Use Make's core dependency engine to ensure that sensitive artifacts (like JIT certificates) are only generated when their sources change, and never persisted to the primary build artifact.
# The K8s Secret depends on a local PEM file
build/$(ENV)/secret.yaml: build/$(ENV)/service.pem
@$(MAKE) -s render-secret-manifest ENV=$(ENV) > $@
# The PEM file depends on the CA key (which lives in a secure vault)
build/$(ENV)/service.pem:
@vault read -field=certificate pki/issue/myservice > $@
# Apply only if the dependency chain is triggered
deploy: build/$(ENV)/secret.yaml
@kubectl apply -f build/$(ENV)/manifest.yaml
@kubectl apply -f $<
Conclusion
The value is not novel syntax. It is a clean boundary.
Helm, CUE, Jsonnet, and Dhall are better at modeling configuration. Make is better at turning a deployment into a reproducible graph: validate, render, diff, install, test, capture evidence, and clean up.
For large deployments, that boundary is often the part that matters most.