Development
This guide covers the internal architecture, conventions, and patterns for building and contributing to daemonless containers.
Architecture Overview
Image Hierarchy
graph TD
A[freebsd/freebsd-runtime:15.1] --> B[daemonless/base:15.1]
B --> C[daemonless/arr-base:15.1]
B --> D[daemonless/nginx-base:15.1]
B --> E[Direct Services]
C --> F[radarr, sonarr, lidarr, prowlarr, jellyfin]
D --> G[nextcloud, vaultwarden, mealie]
E --> H[gitea, traefik, redis, transmission, etc.]
All images inherit from base:15.1 which provides s6 supervision and PUID/PGID support. Specialized bases add runtime dependencies:
- arr-base - .NET runtime and libraries for *arr applications
- nginx-base - nginx pre-installed for web applications
Repository Structure
Each image is a standalone git repo:
Labels Reference
dbuild generate writes these labels into the generated Containerfile* from your compose.yaml metadata — don't add them by hand (see Service Source Files). This section is a reference for what each one means.
io.daemonless.* Labels
| Label | Required | Purpose | Example |
|---|---|---|---|
io.daemonless.port |
Conditional | Primary listening port(s) | "7878", "80,443,8080" |
io.daemonless.category |
Yes | Service classification | "Media Management" |
io.daemonless.packages |
Yes | FreeBSD packages to install | "${PACKAGES}" |
io.daemonless.volumes |
No | Suggested volume mounts | "/movies,/downloads" |
io.daemonless.upstream-url |
No | Version-check API endpoint | "https://api.github.com/repos/Radarr/Radarr/releases/latest" |
io.daemonless.upstream-jq |
No | jq filter to extract the version from that endpoint | ".tag_name" |
io.daemonless.arch |
No | Supported architecture | "amd64" (default) |
io.daemonless.type |
No | Image type (base images only) | "base" |
io.daemonless.wip |
No | Work-in-progress flag | "true" |
io.daemonless.pkg-name |
No | Package name for :pkg builds | "radarr" |
io.daemonless.base |
No | Base image type | "nginx" |
Upstream Version Tracking
For upstream-binary images, version status is tracked from two labels, both rendered from Containerfile*.j2 ARGs:
| Label | Purpose | Example |
|---|---|---|
io.daemonless.upstream-url |
HTTP endpoint returning version info (usually a GitHub releases API URL) | "https://api.github.com/repos/Radarr/Radarr/releases/latest" |
io.daemonless.upstream-jq |
jq filter applied to that response to get the version string |
".tag_name" |
The version pipeline fetches upstream-url and applies upstream-jq. Package-based images skip these and read the version from pkg via io.daemonless.pkg-name instead. (The older upstream-mode/-repo/-package/-branch/-sed labels are obsolete and no longer read.)
Category Values
Media Management- radarr, sonarr, lidarr, prowlarr, overseerrMedia Servers- jellyfin, plex, tautulliDownloaders- sabnzbd, transmissionInfrastructure- traefik, gitea, woodpecker, tailscaleDatabases- redis, immich-postgresPhotos & Media- immich-server, immich-mlUtilities- nextcloud, vaultwarden, mealie, n8n, smokeping
Special Annotations
For .NET applications (arr-base derivatives):
The Source of Truth: compose.yaml
Daemonless uses a standardized compose.yaml file located in the root of each repository to define the image's metadata, dependencies, and configuration. Do not manually add io.daemonless.* labels to your Containerfile.
Instead, define your app in compose.yaml under the x-daemonless block:
Then, run dbuild generate to automatically inject the required io.daemonless.* labels into your Containerfiles and regenerate the README.md.
For a quick map of what belongs in compose.yaml, .daemonless/config.yaml, and Containerfile*.j2, see Service Source Files.
Containerfile Patterns
Standard Pattern (:latest)
Downloads binaries from upstream for bleeding-edge versions:
This is Containerfile.j2; dbuild generate substitutes the {{ ... }} values from compose.yaml:
Package Pattern (:pkg)
Uses FreeBSD packages for stable, tested versions:
Multi-stage Pattern
For Node.js or compiled applications:
Init System (s6)
daemonless containers use s6 for process supervision, providing reliable service management and flexible runtime configuration.
Why s6?
| Benefit | Description |
|---|---|
| Zombie Reaping | Properly reaps zombie processes |
| Auto-restart | Restarts failed services automatically |
| Multi-process | Run helper processes alongside the main app |
| Privilege Drop | Drop privileges while still performing root-level init |
The /init Script
The entrypoint for all containers. Responsibilities:
- Environment Handling — Captures environment variables for supervised services
- Networking — Configures loopback interface (
lo0) for health checks - Initialization — Executes startup scripts in order
- Supervision — Starts s6-svscan to manage processes
Initialization Sequence
When a container starts, /init runs scripts from these directories:
1. Built-in Init (/etc/cont-init.d/)
Part of the container image. Handles:
- Configuring the
bsduser (PUID/PGID) - Setting permissions on
/config - Generating default configuration files
2. Custom Init (/custom-cont-init.d/)
User-provided scripts. Mount your scripts here to run custom initialization:
s6 Service Files
Service Run Script
root/etc/services.d/<app>/run:
Always use exec
The exec command replaces the shell with the application, ensuring proper signal handling and process supervision.
Init Script
root/etc/cont-init.d/20-<app>:
Readiness Check
For services that need health checks before reporting ready:
Environment Variables
Base Container
| Variable | Default | Purpose |
|---|---|---|
PUID |
1000 |
User ID for bsd user |
PGID |
1000 |
Group ID for bsd group |
TZ |
UTC |
Timezone |
Logging (s6)
| Variable | Default | Purpose |
|---|---|---|
S6_LOG_ENABLE |
1 |
Enable s6-log |
S6_LOG_DEST |
/config/logs/daemonless |
Log directory |
S6_LOG_MAX_SIZE |
1048576 |
Max log file size (1MB) |
S6_LOG_MAX_FILES |
10 |
Rotated files to keep |
S6_LOG_STDOUT |
1 |
Mirror logs to stdout |
Log Locations:
| Location | Description |
|---|---|
/config/logs/daemonless/<app>/ |
s6 system logs (stdout/stderr) |
/config/logs/ |
Application-specific logs |
podman logs <container> |
Console output (still works) |
Viewing Logs:
s6-log automatically rotates when files reach S6_LOG_MAX_SIZE. Old logs are named with timestamps and kept up to S6_LOG_MAX_FILES.
.NET Applications
| Variable | Default | Purpose |
|---|---|---|
CLR_OPENSSL_VERSION_OVERRIDE |
35 |
OpenSSL version hint |
DOTNET_SYSTEM_NET_DISABLEIPV6 |
1 |
Disable IPv6 in .NET |
HOME |
/config |
.NET home directory |
Build System
The Daemonless project uses dbuild as its primary build engine. dbuild handles the full lifecycle of an image, from initial build to integration testing and publishing.
Local Building
To build an image locally, navigate to the image repository and use dbuild:
CI/CD Pipeline
Standard .github/workflows/build.yaml for a new image repository:
Legacy CI (Woodpecker as Plan-B)
We previously used Woodpecker CI before migrating to GitHub Actions. Woodpecker serves as our "Plan B" if GHA ever goes down. We still maintain the configuration capability, but we don't currently use it for active builds.
The dbuild ci-run command handles environment preparation, multi-variant building, CIT testing, pushing to the registry, and SBOM generation.
Adding a New Image
1. Create Repository
2. Initialize with dbuild
This creates a starter compose.yaml, Containerfile, .daemonless/config.yaml, and .github/workflows/build.yaml (use --woodpecker instead if you need to scaffold the fallback pipeline).
3. Configure Metadata & Assets
Edit compose.yaml to set your app's title, ports, and category. Then, fetch the visual assets and generate the documentation:
This automatically injects the required io.daemonless.* labels into your Containerfiles and builds a rich README.md using your fetched assets.
4. Create Service Files
5. Test Locally
6. Push to Registry
Conventions Checklist
- Use
fetchinstead ofcurl(included in FreeBSD base) - Clean pkg cache:
pkg clean -ay && rm -rf /var/cache/pkg/* - Set ownership:
chown -R bsd:bsd /config /app - Make scripts executable:
chmod +x /etc/services.d/*/run - Use
s6-setuidgid bsdin run scripts - Create /config directory and set as VOLUME
- Run
dbuild generateto synccompose.yamlmetadata to Containerfiles - Use ARG for BASE_VERSION, PACKAGES, VERSION
- Include
upstream_urlincompose.yamlfor version detection - Add
io.daemonless.wip="true"for incomplete images
Troubleshooting
.NET Apps Won't Start
Ensure ocijail 0.5.0+ is installed and the annotation is set:
See ocijail Native Support for details.
Permission Denied
Check PUID/PGID match host user:
Service Not Starting
Check s6 logs:
Or check init output:
Build Fails on pkg install
Check package name exists: