A Dagger translation of the project’s vars.mk Makefile. Dagger runs each build step inside a container; pipelines are defined in Python (or Go/TypeScript).
Quick Start
# Install Dagger CLI (requires Docker/Podman)
curl -fsSL https://dl.dagger.io/dagger/install.sh | sh
# Run from the repo root — Dagger discovers the module in content/blog/...
dagger -m content/blog/comparing-build-systems/dagger \
call build --src=. --env=prod-obverse \
export --path=public/prod-obverse
# Deploy (token read from environment variable)
dagger -m content/blog/comparing-build-systems/dagger \
call deploy-prod-obverse \
--src=. \
--netlify-token=env:NETLIFY_AUTH_TOKEN
Available Functions
| Function | Description |
|---|---|
git-info |
Return COMMIT_REF for the source directory |
build |
Build Hugo site for any environment |
build-prod-obverse |
Build production site |
build-prod-reverse |
Build production mirror |
check-symlinks |
Exit with error if symlinks exist in content/ |
deploy-prod-obverse |
Deploy to Netlify |
deploy-prod-reverse |
Deploy mirror to Netlify |
cfn-base |
Deploy CFN base stack; return outputs as JSON |
cfn-distrib |
Deploy CFN distribution stack given base outputs |
deploy-test-obverse |
Deploy all test-obverse resources (CFN + S3) |
How This Compares to Make
What Works Well
1. The vars.mk problem doesn’t exist
The entire vars.mk pattern was invented to work around Make’s parse-time $(shell ...) execution.
In Dagger, there is no build-file parse phase that runs shell commands:
# Python: git_info() is a function — it runs only when dagger calls it
@function
async def git_info(self, src: dagger.Directory) -> str:
return await dag.container().from_(GIT_IMAGE)...stdout()
# Make: $(shell git rev-parse HEAD) runs at parse time on EVERY make invocation
$(eval GIT_COMMIT := $(shell git rev-parse HEAD))
The HUGOSOURCES problem (running find over 2500 files on every make help) is also gone.
Dagger never parses a build file by running host commands.
2. Content-addressed caching replaces timestamps and .vars.mk fragments
Make tracks staleness with file timestamps.
The vars.mk pattern caches expensive computations by writing them to files (build/git-info.vars.mk),
so Make can check the file’s mtime against its dependencies.
Dagger’s engine hashes the contents of every input.
A dagger.Directory passed to build() is identified by a Merkle hash of all its files.
If nothing changed, the entire step is a cache hit — no sentinel files, no .vars.mk, no two-phase re-read.
# Dagger: src's content hash is the cache key; no .sentinel file needed
@function
async def build(self, src: dagger.Directory, env: str) -> dagger.Directory:
...
# Make: .sentinel is the cache artifact; depends on vars.mk files and $(HUGOSOURCES)
public/prod-obverse/.sentinel: build/git-info.vars.mk build/hugo-sources.vars.mk $(HUGOSOURCES)
$(call hugobuild,prod-obverse)
3. Secrets are first-class
Credentials in the Make workflow live in environment variables or .env files on the host.
They appear in shell history, process lists, and build logs.
Dagger has dagger.Secret — a typed value that the engine ensures is never logged,
never written to disk, and never appears in the call graph:
@function
async def deploy_prod_obverse(
self,
src: dagger.Directory,
netlify_token: dagger.Secret, # never logged, never on disk
) -> str:
...
.with_secret_variable("NETLIFY_AUTH_TOKEN", netlify_token)
...
# Pass from environment without it appearing in the dagger call graph
dagger call deploy-prod-obverse --netlify-token=env:NETLIFY_AUTH_TOKEN
4. CFN outputs flow as values, not global variables
The vars.mk pattern deploys a CFN stack and emits output keys into a .vars.mk fragment
that gets -include’d into the global make namespace.
Two stacks with the same output key (both emit ContentBucketName) need distinct prefixes
(TEST_OBVERSE_ContentBucketName vs TEST_REVERSE_ContentBucketName) to avoid collision.
In Dagger, cfn_base() returns a JSON string.
The caller passes it to cfn_distrib() as an argument — no global namespace, no prefix gymnastics:
# Dagger: outputs flow as function arguments
base_json = await self.cfn_base(src, "test-obverse", ...)
await self.cfn_distrib(src, "test-obverse", base_json, ...)
# Make: outputs become global vars via -include; prefixes prevent collisions
-include build/cfn-test-obverse-base.vars.mk
# ... later:
ContentBucketName=$(TEST_OBVERSE_ContentBucketName)
5. Hermetic builds
Every tool (Hugo, AWS CLI, netlify-cli, git) runs in a container image with a pinned version. The build does not depend on what is installed on the host machine: a developer on macOS and CI on Linux produce identical builds because they run the same container.
With Make, make deploy uses whatever hugo, aws, and netlify are in $PATH.
Version drift between machines is a persistent source of “works on my machine” bugs.
6. Explicit dependency ordering
Make’s deploy-test-obverse encodes ordering implicitly via left-to-right prerequisites:
deploy-test-obverse: build/cfn-test-obverse-base.vars.mk \
deploy-test-obverse-hugo \
build/cfn-test-obverse-distrib.vars.mk
In Dagger, cfn_distrib() takes base_outputs_json as a parameter — it structurally
cannot run before cfn_base() returns. The dependency is enforced by the type system,
not by ordering convention.
What Doesn’t Work Well / Trade-offs
1. Requires a container runtime — not pre-installed anywhere
Make is on every Unix system. Dagger requires:
- Docker or Podman
- The Dagger engine (downloaded on first run, ~200 MB)
- The Dagger CLI
For local development on a laptop, this is often already available. For a minimal CI environment or a bare server, it’s a significant prerequisite.
2. No pattern rules
Make’s %.exifstripped: | % creates one rule per image, tracking each file individually.
Dagger has no equivalent. The whole EXIF strip runs or doesn’t run based on
whether src has changed — per-file granularity is not available.
In practice, Dagger’s content-addressed caching means that if nothing in src changed,
the step is a full cache hit. When any image changes, all images are re-processed.
For projects with many expensive per-file operations this is a real gap.
3. Rsync to the onion sites is awkward
deploy-onion-obverse and deploy-onion-reverse rsync to a remote server over SSH.
From a container this requires:
- Mounting an SSH private key as a
dagger.Secret - Adding the remote host to
known_hostsinside the container - Or using a Dagger module that wraps SSH
This is solvable but meaningfully more complex than the Make version,
which just runs rsync with whatever SSH keys are in ~/.ssh/:
# Make: rsync from host, using host SSH keys — two lines
deploy-onion-obverse: ...
$(call rsynconion,onion-obverse,/var/lib/nginx/home-obverse/)
The onion deploy targets are omitted from main.py for this reason.
4. Slower for incremental local builds
On a clean build, Dagger is often faster than Make because container layer caching avoids redundant work more aggressively.
For incremental builds where one file changed, Dagger has overhead:
- Serializing the changed
dagger.Directoryto the engine - Container startup latency per step
- Cache miss propagation through the layer graph
make with a warmed timestamp cache is nearly instantaneous for small changes.
Dagger’s latency floor is higher.
5. Interactive workflows don’t fit
make dev runs hugo server with live reload — an interactive process that serves HTTP
and watches the filesystem. Dagger is designed for non-interactive pipelines.
Running a long-lived server inside a Dagger function is possible but unnatural.
6. Must be installed, and the module must be discoverable
Unlike running make from the repo root, you must either:
- Pass
-m path/to/moduleon every invocation, or - Place the Dagger module at the repo root (or add a root-level wrapper)
The module in this post lives in content/blog/comparing-build-systems/dagger/,
so every call requires -m content/blog/comparing-build-systems/dagger.
Structural Differences Summary
| Aspect | Make (vars.mk) | Dagger |
|---|---|---|
| Syntax | Makefile DSL | Python (or Go / TypeScript) |
| Execution environment | Host machine | Containers (OCI) |
| Caching | File timestamps | Content-addressed (Merkle hash of inputs) |
| Parse-time overhead | Yes: $(shell ...) on every invocation |
No: Python code runs only inside containers |
| Intermediate state | .vars.mk fragments -include’d into make namespace |
Function return values (strings, dagger.Directory) |
| CFN output passing | Global make variables via -include + prefix namespace |
Function arguments; no global namespace |
| Secrets | Env vars / .env files on host |
dagger.Secret — never logged or written to disk |
| Pattern rules | %.exifstripped: | % |
Not supported |
| Tool versions | Whatever is in $PATH on the host |
Pinned container images |
| Reproducibility | Depends on host environment | Hermetic: same containers everywhere |
| Interactive workflows | Natural (hugo server, make dev) |
Awkward; Dagger is designed for pipelines |
| SSH / rsync | Uses host ~/.ssh/ naturally |
Requires mounting secrets into containers |
| Installation | Universal (pre-installed) | Requires Docker + Dagger CLI (~200 MB engine) |
Verdict
Dagger solves a fundamentally different problem than the vars.mk pattern. vars.mk works around Make’s architectural limitations (parse-time shell execution, no typed values). Dagger replaces the entire host-command model with container-based execution.
The key wins over the vars.mk Makefile:
- The vars.mk pattern is unnecessary — no parse-time overhead, no
-includedance - Content-addressed caching is strictly more correct than timestamp-based caching
- Secrets are never exposed to the host environment or build logs
- CFN outputs flow as typed function values; no global variable namespace or prefix collisions
- Hermetic builds: tool versions are pinned in container images, not host-installed
The main losses:
- Requires Docker and the Dagger engine — not pre-installed anywhere
- No pattern rules; EXIF stripping loses per-file granularity
- Interactive dev workflows (
hugo server) don’t fit the pipeline model - SSH/rsync to remote servers requires non-trivial secret plumbing
- Incremental local builds have higher latency than Make with warm timestamps
Compared to the other build systems in this series: Dagger is the only one that eliminates the host environment as a variable. If reproducibility and secrets hygiene are the priority, Dagger wins clearly. If you want something you can run on any machine without setup, Make still wins. pydoit and Task are closer substitutes for Make day-to-day; Dagger is a different kind of tool that happens to overlap with build systems.