Build, test, and push FreeBSD OCI container images.
dbuild is a build tool for daemonless container images. It reads a project directory containing Containerfile templates and an optional .daemonless/config.yaml, then handles the full image lifecycle: build, integration test, SBOM generation, and registry push.
It runs the same way locally and in CI. GitHub Actions and Woodpecker call dbuild — dbuild handles the FreeBSD-specific logic (variant detection, architecture mapping, OCI labels, registry auth, skip directives, SBOM generation) so CI workflows stay generic.
GitHub Actions / Woodpecker / local
└── dbuild detect → build matrix
└── dbuild build → podman build per variant
└── dbuild test → container integration tests
└── dbuild sbom → CycloneDX SBOM
└── dbuild push → push to ghcr.io
└── dbuild manifest → multi-arch manifest lists
- Python 3.11+
- PyYAML
- Podman (with
doason FreeBSD when not root) - Buildah (for OCI label application and SBOM generation)
Optional:
| Feature | Packages |
|---|---|
| Multi-arch manifests | skopeo |
| Compose testing | podman-compose |
| SBOM generation | trivy |
| Screenshot testing | py311-selenium, py311-scikit-image, chromium, chromedriver |
pip install .
# With dev tools (pytest + ruff)
pip install ".[dev]"Or use without installing:
PYTHONPATH=/path/to/dbuild python3 -m dbuild info# Scaffold a new image project
mkdir myapp && cd myapp
dbuild init
# From an existing image repo (e.g. radarr/)
cd radarr
# See what would be built
dbuild info
# Build all variants
dbuild build
# Build one variant
dbuild build --variant pkg
# Test built images
dbuild test
# Build and push in one step
dbuild build --pushdbuild expects this directory structure in each image repo:
myapp/
Containerfile # upstream binary build (:latest tag)
Containerfile.pkg # FreeBSD package build (:pkg tag) - optional
root/ # files copied into the container
.daemonless/
config.yaml # build + test configuration - optional
baseline.png # screenshot baseline - optional
baselines/ # per-variant baselines (baseline-pkg.png) - optional
compose.yaml # multi-service test stack - optional
If no config.yaml exists, dbuild auto-detects variants from the Containerfiles present:
| File | Variant tag |
|---|---|
Containerfile |
:latest |
Containerfile.<suffix> |
:<suffix> (e.g. Containerfile.pkg → :pkg) |
.daemonless/config.yaml (or .dbuild.yaml):
# Image type: "app" (default) or "base"
type: app
# Build configuration
build:
auto_version: true # extract version from built image
pkg_name: myapp # FreeBSD package name (for pkg variants)
architectures: [amd64, aarch64] # target architectures (default: [amd64])
variants:
- tag: latest
containerfile: Containerfile
default: true
- tag: pkg
containerfile: Containerfile.pkg
args:
BASE_VERSION: "15-quarterly"
aliases: [pkg-quarterly]
- tag: pkg-latest
containerfile: Containerfile.pkg
args:
BASE_VERSION: "15-latest"
# Container integration test configuration
cit:
mode: health # shell | port | health | screenshot | command
port: 8080
health: /api/health # health endpoint path
wait: 120 # startup timeout (seconds)
ready: "Server started" # log ready-pattern (regex)
https: false # use HTTPS for health checks
screenshot: /web # custom screenshot URL path
screenshot_wait: 5 # seconds to wait before screenshot
compose: false # use podman-compose for testing
annotations: # container annotations for testing
- "org.freebsd.jail.allow.mlock=true"command mode is for one-shot CLI/tool images (no long-lived service to
probe). It runs the image to completion and asserts the exit code, plus an
optional output regex:
cit:
mode: command
command: ["--version"] # args after the entrypoint (omit = image CMD)
expect_exit: 0 # exit code that means success (default 0)
expect_output: "2\\.7\\.5" # regex that must match stdout/stderr/usr/local/etc/daemonless.yaml is an optional host-local config file for
settings that should apply on this builder, not in every image repo. If dbuild
is installed under a different prefix, use that prefix instead. For a local pkg
cache, it only needs one key:
pkg_cache_url: pkg-cache.example.lan:8080When pkg_cache_url is set, dbuild build injects it as the
PKG_CACHE_URL build argument. Containerfiles that opt in can use that value
to point FreeBSD pkg at the local cache during the build. If the global file
or key is missing, nothing is injected.
Build container images for all (or selected) variants.
dbuild build # all variants
dbuild build --variant latest # one variant
dbuild build --arch aarch64 # cross-architecture
dbuild build --push # build + push to registryBuild output is tagged as {registry}/{image}:build-{tag} (e.g. ghcr.io/daemonless/radarr:build-pkg).
Run container integration tests against built images.
dbuild test # test all variants
dbuild test --variant pkg # test one variant
dbuild test --json result.json # write JSON resultTest modes are cumulative — each includes all tests below it:
| Mode | Tests |
|---|---|
screenshot |
health + capture screenshot + visual verification |
health |
port + HTTP endpoint responds (2xx/4xx = ok) |
port |
shell + TCP port is listening |
shell |
container starts, can exec into it |
Auto-detection: If no mode is set in config, dbuild picks the highest applicable mode:
baseline.pngexists and screenshot deps installed →screenshot- Health endpoint defined (config or OCI label) →
health - Port defined (config or OCI label) →
port - Otherwise →
shell
If screenshot dependencies aren't installed, it downgrades automatically and tells you what's missing.
OCI labels: dbuild reads io.daemonless.port, io.daemonless.healthcheck-url, and org.freebsd.jail.* labels from the built image. Config values override labels.
Tag and push built images to the configured registry.
dbuild push
dbuild push --variant latestSupports Docker Hub mirroring when DOCKERHUB_USERNAME and DOCKERHUB_TOKEN are set.
Generate a CycloneDX SBOM for built images. Uses trivy for application-level dependencies and pkg query for FreeBSD packages.
dbuild sbom
dbuild sbom --variant pkgOutput is written to sbom-results/{image}-{tag}-sbom.json.
Create and push multi-architecture manifest lists. Only useful when architectures has more than one entry.
dbuild manifestFor each variant tag (plus aliases), creates a manifest referencing the architecture-specific images:
latest→latest(amd64) +latest-arm64(aarch64)pkg→pkg(amd64) +pkg-arm64(aarch64)
Output the build matrix as JSON for CI integration.
dbuild detect # plain JSON to stdout
dbuild detect --format github # write to $GITHUB_OUTPUT
dbuild detect --format woodpecker # JSON to stdoutHuman-readable overview of detected configuration.
$ dbuild info
=== Image: ghcr.io/daemonless/radarr ===
[info] Type: app
[info] Architectures: amd64
[info] Variants: 3
[info] latest (amd64) -> Containerfile
[info] pkg (amd64) -> Containerfile.pkg
[info] BASE_VERSION=15-quarterly
[info] pkg-latest (amd64) -> Containerfile.pkg
[info] BASE_VERSION=15-latest
[info] Test: mode= port=7878Scaffold a new dbuild project with starter files.
dbuild init # config.yaml, compose.yaml, Containerfile.j2, root/
dbuild init --github # + GitHub Actions workflow
dbuild init --woodpecker # + Woodpecker CI pipelineThe image is a single process on base-exec: /init runs the setup script
root/daemonless/init.d/20-<name> as root, then execs the launcher
root/daemonless/libexec/<name> (the CMD) as bsd. Health is the base's
/healthz against HEALTHCHECK_URL. The Containerfile*.j2 keep the title,
description, category, ports and volumes as placeholders: dbuild generate
fills them from compose.yaml, which stays the one place to edit them. A
pkg-only scaffold makes pkg the default tag with a latest alias.
From a FreeBSD port, the port fills in what it knows:
dbuild init --freebsd-port net-p2p/transmission-components
dbuild init --freebsd-port gitea --port 3000 # bare name: looked up in the tree, then pkg
dbuild init --freebsd-port https://www.freshports.org/sysutils/backrest/
dbuild init --freebsd-port net-p2p/qbittorrent --flavors nox # one pkg tag per flavor| From the port | Goes to |
|---|---|
COMMENT, WWW, LICENSE (+LICENSE_COMB) as SPDX, category |
compose.yaml, image labels |
rc script command=/procname= (through /usr/sbin/daemon), plist bin/ entries |
the launcher, with the rc flags and conf dirs as a comment |
rc *_port default or a localhost:PORT in pkg-message |
cit.port and the compose ports (else 8080 and a warning) |
FLAVORS |
listed; --flavors a,b scaffolds pkg-a, pkg-b |
Defaults change with --freebsd-port: variants are pkg,pkg-latest, and from a
directory of image repos the files land in ./<name>/ (override with --dir).
Without a ports tree ($PORTSDIR, /usr/ports) the labels come from pkg rquery;
the launcher then needs the binary set by hand. Every scaffold ends with the
items left to review and the next commands (dbuild logo, dbuild generate,
dbuild build, dbuild test).
--variant TAG filter to a single variant
--arch ARCH override target architecture (amd64, aarch64, riscv64)
--registry URL override the container registry
--push push images after building
-v, --verbose enable debug logging
Add [skip <step>] to a commit message to skip CI steps:
fix: update config [skip test] # skip testing
feat: bump version [skip push] # skip all pushes
chore: docs only [skip push:dockerhub] # skip Docker Hub mirror only
chore: update readme [skip sbom] # skip SBOM generation
fix: ci [skip test] [skip sbom] # skip multiple steps
[skip push] also skips push:dockerhub. [skip push:dockerhub] only skips the Docker Hub mirror.
| Variable | Default | Description |
|---|---|---|
DBUILD_REGISTRY |
ghcr.io/daemonless |
Default container registry |
GITHUB_TOKEN |
Forwarded as build secret + registry auth | |
GITHUB_ACTOR |
Registry login username | |
DOCKERHUB_USERNAME |
Enable Docker Hub mirroring | |
DOCKERHUB_TOKEN |
Docker Hub auth token | |
CHROME_BIN |
/usr/local/bin/chrome |
Chrome binary for screenshots |
CHROMEDRIVER_BIN |
/usr/local/bin/chromedriver |
ChromeDriver binary |
SCREENSHOT_SIZE |
1920,1080 |
Screenshot viewport size |
VERIFY_SSIM_THRESHOLD |
0.95 |
SSIM threshold for baseline comparison |
dbuild init --githubThis generates .github/workflows/build.yaml which:
- Runs
dbuild detect --format githubto generate the build matrix - Spins up a FreeBSD VM per matrix entry (via vmactions/freebsd-vm)
- Runs
dbuild build → test → sbom → pushinside the VM
dbuild init --woodpeckerGenerates .woodpecker.yaml. The pipeline fetches dbuild-ci.sh which runs the full build pipeline on the native FreeBSD agent.
# Full pipeline (same as CI)
dbuild build --variant latest
dbuild test --variant latest
dbuild sbom --variant latest
# Transfer to another host
doas podman save ghcr.io/daemonless/myapp:build-latest | ssh jupiter doas podman loaddbuild test --json result.json writes:
{
"image": "ghcr.io/daemonless/radarr:build-pkg",
"mode": "health",
"timestamp": "2026-02-08T17:01:11Z",
"shell": "pass",
"ready": "pass",
"port": "pass",
"health": "pass",
"screenshot": "skip",
"verify": "skip",
"result": "pass"
}Each test is "pass", "fail", or "skip". The ready key can also be
"port": the port accepted a connection before any log line matched the
ready pattern, so cit.ready (or the built-in patterns) is unused for this
image; or "timeout": neither happened within wait, the run continued and
the port/health checks decided. A timeout on an image that then passes
means a stale ready: regex (or, on an s6 image, a missing notification-fd).
pip install -e ".[dev]"
# Run tests
pytest
# Lint
ruff check dbuild/ tests/BSD-2-Clause