Back to home

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 define templates
  • 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.