Quality Gates (CIT)
flowchart LR
subgraph dbuild ["dbuild (The Engine)"]
direction LR
B[Build] --> T[Test]
T --> P[Push]
end
subgraph CIT ["CIT (The Gates)"]
direction TB
T1[Shell]
T2[Port]
T3[Health]
T4[Screenshot]
end
T -.-> CITWhile dbuild manages the container lifecycle (start, stop, cleanup), CIT defines the success criteria through a cumulative mode system.
Cumulative modes#
Each mode includes all checks from lower modes:
screenshot = health + visual regression
health = port + HTTP endpoint check
port = shell + TCP port listening
shell = container starts successfully
| Mode | What it checks | Use case |
|---|---|---|
shell |
Container starts, echo ok via exec |
Base images |
port |
Shell + TCP port is listening | Services with network listeners |
health |
Port + HTTP endpoint returns non-error | Web apps with health endpoints |
screenshot |
Health + visual regression against baseline | Web UIs |
command |
Image runs to completion, exit code + output regex | One-shot CLI tools (run-and-exit) |
command mode sits outside the cumulative ladder — it's for images whose entrypoint runs once and exits (e.g. ffmpeg, immich-cli), so there's no live process for shell/port/health/screenshot to probe:
cit:
mode: command
command: ["--version"] # args after the entrypoint (omit = image CMD)
expect_exit: 0 # exit code that means success (default 0)
expect_output: 'ffmpeg version \d' # regex matched against stdout/stderrcommand mode is podman-only and does not support compose: true.
Mode auto-detection#
If no mode is set in config, CIT picks the highest applicable mode: a screenshot baseline image → screenshot; health set → health; port set → port; otherwise → shell.
Gate details#
Shell test
Verifies the container starts successfully: s6-overlay initializes, services start without errors, and echo ok succeeds via exec. This is the baseline gate — every mode includes it.
Port binding
Waits for the application to bind to a TCP port:
cit:
port: 7878CIT polls the socket until it becomes available or the wait timeout expires.
Health check
Sends an HTTP GET request to the specified endpoint:
cit:
port: 7878
health: /pingExpects a non-error HTTP response (2xx or 4xx). A 502 or 503 indicates the app isn't ready yet, and CIT will retry. For HTTPS-only apps, add https: true.
Visual regression (SSIM)
Captures a browser screenshot and compares it against a known-good baseline using Structural Similarity Index (SSIM):
cit:
mode: screenshot
port: 7878
health: /ping
screenshot_wait: 10- Threshold: SSIM ≥ 0.95 (95% structural similarity)
- Catches UI regressions, broken CSS, missing assets
- Uses scikit-image for SSIM comparison and Selenium + Chromium for capture
- If screenshot dependencies are missing, the mode automatically downgrades to
health
Baseline search order: .daemonless/baseline-{tag}.png → .daemonless/baselines/baseline-{tag}.png → .daemonless/baseline.png → .daemonless/baselines/baseline.png. If a baseline exists and no mode is configured, screenshot mode is auto-selected.
Readiness detection#
Before running port/health checks, CIT watches container logs for a readiness pattern. The default pattern matches common startup messages:
Warmup complete | services.d.*done | Application started | Startup complete | listening on | is ready
Override with the ready field:
cit:
ready: "Server initialized"
wait: 300The wait timeout (default: 120 seconds) applies to the readiness check. If the pattern isn't seen within the timeout, CIT proceeds to port/health checks anyway — the timeout is not fatal on its own.
Compose testing#
For multi-service stacks (app + database), set compose: true:
cit:
compose: true
port: 8080
health: /api/healthThis uses podman-compose with the compose file at .daemonless/compose.yaml — separate from the top-level compose.yaml, so you can define a test-specific stack without touching the production deployment file. Shell exec tests are skipped for compose stacks since they don't support single-container exec. If .daemonless/compose.yaml is absent when compose: true is set, the test fails immediately.
Testing backends#
By default, dbuild test runs the container using Podman. For images deployed to FreeBSD jails, you can also test with AppJail to validate behavior in a real jail context:
dbuild test # Podman only (default)
dbuild test --backend appjail # AppJail only
dbuild test --backend all # BothThe AppJail backend runs the container via appjail oci run instead of podman run, exercising the full jail stack — shared host network, syscall restrictions, mount namespaces, and jail annotations all apply. It's auto-selected when appjail: true is set in compose.yaml and AppJail is installed on the host; if configured but not installed, dbuild warns and falls back to Podman. compose: true is Podman-only, so the AppJail backend is skipped when compose mode is set.
dbuild generate produces three AppJail deployment files (Makejail, appjail-director.yml, .env) from bundled templates. To customize any of them, place an override in .daemonless/appjail/ — any file found there is copied as-is, and files not present fall back to the auto-generated template.
Jail annotations#
Some applications require specific FreeBSD jail permissions:
cit:
annotations:
- "org.freebsd.jail.allow.mlock=true"
- "org.freebsd.jail.allow.sysvipc=true"| Annotation | Required by |
|---|---|
allow.mlock |
.NET apps (Radarr, Sonarr, Prowlarr, Lidarr) |
allow.sysvipc |
PostgreSQL |
These annotations are passed to podman run during testing; in production they're set via --annotation in the deploy playbook.
Platform QA#
CIT serves as a functional regression suite for the entire FreeBSD container stack — the FreeBSD 15 kernel (syscalls, socket binding, process management), the ocijail runtime (jail isolation), and s6-overlay (init system behavior). Every image build runs CIT in a real FreeBSD VM, not emulation. The push step is unreachable if any gate fails, so ghcr.io/daemonless/* contains only validated containers.
flowchart LR
A[Build Image] --> B[Run CIT]
B -->|All Pass| C[Push to ghcr.io]
B -->|Any Fail| D[Build Fails]
D --> E[No Push]Configuration reference#
Full cit: schema for .daemonless/config.yaml:
cit:
mode: health # shell | port | health | screenshot | command (auto-detected if omitted)
port: 8080 # TCP port to check
health: /health # Health endpoint path
https: false # Use HTTPS for health checks
wait: 120 # Startup timeout in seconds
ready: "Server started" # Log pattern to wait for before testing (regex)
compose: false # Use podman-compose for multi-service stacks
screenshot_wait: 10 # Extra wait in seconds before screenshot capture
annotations:
- "org.freebsd.jail.allow.mlock=true"
- "org.freebsd.jail.allow.sysvipc=true"| Field | Default | Description |
|---|---|---|
mode |
Auto-detected | Test mode: shell, port, health, screenshot, or command |
port |
— | TCP port to check |
health |
/ |
Health endpoint path |
https |
false |
Use HTTPS for health checks |
wait |
120 |
Startup timeout in seconds |
ready |
Built-in pattern | Log regex to wait for before testing |
compose |
false |
Use podman-compose with .daemonless/compose.yaml |
screenshot_wait |
— | Extra seconds to wait before screenshot capture |
annotations |
[] |
Jail annotations for the test container |
command |
[] (image CMD) |
command mode: args appended to the entrypoint |
expect_exit |
0 |
command mode: exit code that counts as success |
expect_output |
— | command mode: regex that must match stdout/stderr |
Every gate on this page runs automatically on every push, on real FreeBSD VMs.