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 + Containerfile
dbuild init --github # + GitHub Actions workflow
dbuild init --woodpecker # + Woodpecker CI pipeline--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
"timeout": the log ready-pattern never matched but the run continued —
worth checking for a missing s6 notification-fd or a stale ready: regex.
pip install -e ".[dev]"
# Run tests
pytest
# Lint
ruff check dbuild/ tests/BSD-2-Clause