/blog/

0001 0101 Dagger Build for me.micahrl.com

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_hosts inside 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.Directory to 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/module on 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:

  1. The vars.mk pattern is unnecessary — no parse-time overhead, no -include dance
  2. Content-addressed caching is strictly more correct than timestamp-based caching
  3. Secrets are never exposed to the host environment or build logs
  4. CFN outputs flow as typed function values; no global variable namespace or prefix collisions
  5. Hermetic builds: tool versions are pinned in container images, not host-installed

The main losses:

  1. Requires Docker and the Dagger engine — not pre-installed anywhere
  2. No pattern rules; EXIF stripping loses per-file granularity
  3. Interactive dev workflows (hugo server) don’t fit the pipeline model
  4. SSH/rsync to remote servers requires non-trivial secret plumbing
  5. 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.

Responses

Webmentions

Hosted on remote sites, and collected here via Webmention.io (thanks!).

Comments

Comments are hosted on this site and powered by Remark42 (thanks!).