Skip to main content

Docker Image Builder

info

While the Docker Image Builder is not a core function of dinghy, we use it to build the dinghy engine images themselves and want to package this neat functionality so developers can build their own image catalogues with a single CLI verb — content-hashed, multi-arch, EJS-templated.

Simple setup

tip

A folder with a single Dockerfile is all you need to build an image.

The docker commands wrap Docker BuildKit with sensible defaults. Instead of writing per-image build scripts, juggling tags by hand, or wiring up CI matrices for multi-arch, you drop a Dockerfile into a folder and run one command.

Image source

docker/images/my-image/Dockerfile
FROM alpine:3
RUN apk add --no-cache curl

Helper script to create the file:

mkdir -p docker/images/my-image
cat > docker/images/my-image/Dockerfile <<'EOF'
FROM alpine:3
RUN apk add --no-cache curl
EOF

Build the image

dinghy docker build

You get a content-hashed tag like myrepo/myproject:my-image-83a999e1.... The hash covers every file in the image folder, so identical sources always produce the same tag — perfect for layer reuse and reproducible deploys.

dinghy docker build

dinghy docker build [image] [options]
OptionDefaultDescription
--sourcedocker/imagesSource folder containing image subdirectories
--repoDINGHY_DOCKER_BUILD_REPOTarget Docker repository (set in .dinghyrc)
--pushfalsePush built images to registry (auto-enabled in CI)
--skip-localfalseSkip build if the tag already exists locally
--archhost arch (or DINGHY_DOCKER_SUPPORTED_ARCHS)Architectures to build. Single value → single-arch with --platform; multiple → multi-arch + manifest
--dryrunfalsePreview what would be built without executing

If no image argument is given, all images in the source folder are built in order.

Image directory layout

Only a Dockerfile is required. Everything else is optional:

docker/images/
├── 10-base/
│ ├── Dockerfile # required (or Dockerfile.ejs)
│ ├── Dockerfile.ejs # optional: EJS template → renders to Dockerfile
│ ├── Dockerfile.dockerignore # optional: restricts the build context
│ ├── versions.json # recommended: version pins (VERSION_*)
│ ├── fs-root/ # optional: tree copied into container root
│ ├── prebuild.sh # optional: runs before hash + build
│ └── postbuild.sh # optional: runs after build (always)
├── 20-app/
│ └── Dockerfile
└── 30-worker/
└── Dockerfile
FileRequiredPurpose
DockerfileYes (or .ejs)Docker build definition
Dockerfile.ejsNoEJS template — rendered to Dockerfile before build
Dockerfile.dockerignoreNoRestricts the docker build context to the paths you opt in
versions.jsonRecommendedAll version pins — exposed as VERSION_* to EJS templates
fs-root/NoFiles copied into the image (mirrors the container filesystem)
prebuild.shNoShell script run before hash + build (its files feed the hash)
postbuild.shNoShell script run after build (always — even on failure or skip)

Naming and build order

Subdirectories are sorted alphabetically and built in that order. Use numeric prefixes to control dependency chains — earlier images become available as template variables for later ones:

10-base          → image name: base       → tag var: DOCKER_IMAGE_BASE_TAG
15-yarn-base → image name: yarn-base → tag var: DOCKER_IMAGE_YARN_BASE_TAG
40-site → image name: site

The image name is the segment after the first dash. Folders without a dash use the full folder name.

EJS templating

Files ending in .ejs are rendered with EJS before the build. The output replaces the .ejs extension (Dockerfile.ejsDockerfile).

Available template variables

VariableSourceExample
VERSION_*versions.json keys (uppercased)VERSION_DENO2.8.1
DOCKER_IMAGE_*_TAGTag of a previously built image in this runDOCKER_IMAGE_BASE_TAG
DOCKER_IMAGE_*_VERSIONVersion portion of that tagDOCKER_IMAGE_BASE_VERSION
VERSION_RELEASEGit-derived release version0.1.310-20260413-...
VERSION_BASEFrom .base-version file (if present)
Environment variablesAll Deno.env entriesHOME, PATH, etc.

The variable key for an image name uppercases it and replaces dashes with underscores: yarn-baseDOCKER_IMAGE_YARN_BASE_TAG.

Example: chained image with version pinning

docker/images/40-site/Dockerfile.ejs
ARG BUILD_ARCH=arm64
FROM <%= DOCKER_IMAGE_YARN_BASE_TAG %>-linux-$BUILD_ARCH

ENV NODE_VERSION=<%= VERSION_NODEJS %> \
YARN_VERSION=<%= VERSION_YARN %>

COPY docker/images/40-site/fs-root /
RUN cd /workspace/.dinghy/site && yarn install --immutable
docker/images/40-site/versions.json
{
"nodejs": "24.14.0",
"yarn": "1.22.22"
}

Image tagging

Tags are content-hashed for immutability — identical source produces the same tag, maximising Docker layer reuse and giving you reproducible deploys.

  • Standard images: {repo}:{name}-{md5hash}
    • Example: dinghydev/dinghy:base-83a999e1b7171df882f70c543d8e6f40
  • Multi-arch tags: {tag}-linux-{arch} per architecture, plus a manifest combining them under the base tag

The hash covers every non-.ejs file in the image directory, the base version, and any files explicitly pulled in via Dockerfile.dockerignore.

Custom tag via versions.json

By default an image is published as {repo}:{name}-{md5hash}, where {name} is the folder name and the hash is content-derived. When you want a different name — for example, to bake the upstream base version into the tag so two builds of the same folder are distinguishable — add a tag entry to that image's versions.json:

docker/images/10-base/versions.json
{
"tag": "base-${VERSION_BASE}-${IMAGE_HASH}"
}

The value is a template — uppercase ${VAR} placeholders are substituted before the tag is applied. Useful variables:

PlaceholderExpands to
${IMAGE_HASH}Content hash of this image's source files
${VERSION_BASE}From .base-version file (if present)
${VERSION_RELEASE}Git-derived release version
${VAR}Any other uppercase build-context variable
${VAR-default}VAR if set, otherwise the literal default

Resolution depends on whether the expanded value contains a colon:

  • No colon — prefixed with the build repo: ${repo}:${value}
    • "tag": "base-${VERSION_BASE}-${IMAGE_HASH}"myorg/myproject:base-12.5-abc123
  • Contains colon — used as a fully-qualified image reference, as-is
    • "tag": "gcr.io/myorg/site:${IMAGE_HASH}"gcr.io/myorg/site:abc123

Each image's versions.json controls only its own tag; the override does not leak to other images in the same build.

note

This override sets the name of the image at build time. It is not for promoting tags like latest or stable — promotions belong to a separate release-lifecycle step (e.g. retag-and-push after acceptance).

Multi-architecture builds

Multi-arch is opt-in. Set DINGHY_DOCKER_SUPPORTED_ARCHS in .dinghyrc to provide the default arch list — --arch then defaults to that list. When the variable is unset or false, --arch defaults to the host arch only.

Whether a build takes the multi-arch path is decided by the number of architectures, not a separate flag:

  • More than one arch → multi-arch path: each arch builds as {tag}-linux-{arch}, combined under a manifest at {tag}.
  • Single arch → bare-tag build with --platform linux/{arch}. When the arch matches the host this is a no-op for buildkit; when it differs, the build runs cross-arch via QEMU.
.dinghyrc
DINGHY_DOCKER_SUPPORTED_ARCHS=arm64,amd64

Each architecture is built separately with --platform linux/{arch} and combined under a manifest. If your Dockerfile contains ARG BUILD_ARCH, the builder passes it as a build arg automatically:

ARG BUILD_ARCH=arm64
FROM my-base-image-linux-$BUILD_ARCH
ARG BUILD_ARCH

RUN if [ "$BUILD_ARCH" = "arm64" ]; then ARCH="aarch64"; else ARCH="x86_64"; fi \
&& curl "https://example.com/tool-${ARCH}.tar.gz" -o tool.tar.gz

Custom arch via versions.json

By default every image in the build uses the same arch list — the project's --arch value (which defaults to DINGHY_DOCKER_SUPPORTED_ARCHS). When a specific image needs a different list — for example one image is arm64-only, or an extra arch is needed just for one image — add an arch entry to that image's versions.json:

docker/images/10-base/versions.json
{
"arch": "arm64,amd64"
}

The value is a comma-separated list of Docker arch names; resolution follows the same rules as the project-wide arch list (see above). Each image's versions.json controls only its own arch list; the override does not leak to other images in the same build.

Configuration

.dinghyrc

Most docker-builder configuration lives in .dinghyrc so it's shared by every contributor and the CI pipeline:

.dinghyrc
DINGHY_DOCKER_BUILD_REPO=myorg/myproject
DINGHY_DOCKER_SUPPORTED_ARCHS=arm64,amd64
VariablePurpose
DINGHY_DOCKER_BUILD_REPODefault target repository — avoids passing --repo on every build
DINGHY_DOCKER_SUPPORTED_ARCHSDefault arch list (comma-separated). >1 entry triggers multi-arch + manifest

Dockerfile.dockerignore

Keep the build context minimal — only the files an image actually needs end up in the hash and the build:

docker/images/10-base/Dockerfile.dockerignore
# Ignore everything by default
**

# Allow only this image's fs-root
!/docker/images/10-base/fs-root/**

Build hooks

Pre-/post-build scripts handle path adjustments or external setup that does not belong in the Dockerfile itself. prebuild.sh runs before the image hash is computed, so any files it creates or modifies are part of the content hash — useful for generating versions.json or staging files into the image folder at build time. postbuild.sh runs even when the build fails (or is skipped because the tag already exists), so it is the right place to undo any temporary edits made by prebuild.sh.

docker/images/10-base/prebuild.sh
#!/bin/bash
# Rewrite local-only paths so the build context is self-contained.
sed -i 's|../../core|file:///workspace/.dinghy/core|g' deno.jsonc
docker/images/10-base/postbuild.sh
#!/bin/bash
# Restore the original paths after the build.
sed -i 's|file:///workspace/.dinghy/core|../../core|g' deno.jsonc

Patterns from sample images

Reference: docker/images/ in the dinghy monorepo for working examples of every pattern below.

Minimal image

FROM denoland/deno:debian-<%= VERSION_DENO %>

RUN apt-get update && apt-get install -y git curl jq openssh-client
COPY docker/images/10-base/fs-root /

Chained image

ARG BUILD_ARCH=arm64
FROM <%= DOCKER_IMAGE_YARN_BASE_TAG %>-linux-$BUILD_ARCH

COPY docker/images/40-site/fs-root /

Multi-stage build

ARG BUILD_ARCH=arm64
FROM golang:<%= VERSION_GOLANG %> AS builder
RUN curl -sLO https://example.com/tool-<%= VERSION_TOOL %>.tar.gz \
&& tar xzf tool-*.tar.gz && cd tool-* && make release \
&& cp bin/tool /tool

FROM <%= DOCKER_IMAGE_BASE_TAG %>-linux-$BUILD_ARCH
ARG BUILD_ARCH
COPY --from=builder /tool /usr/bin/