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] --> B[daemonless/base:15]
B --> C[daemonless/arr-base:15]
B --> D[daemonless/nginx-base:15]
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 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:
<app>/
├── Containerfile # Build from upstream binaries (:latest tag)
├── Containerfile.pkg # Build from FreeBSD packages (:pkg tag)
├── .woodpecker.yml # CI/CD pipeline
├── root/ # Files copied into container
│ └── etc/
│ ├── cont-init.d/ # Initialization scripts
│ │ └── 20-<app> # App-specific init
│ └── services.d/ # s6 service definitions
│ └── <app>/
│ ├── run # Service start script
│ └── run.pkg # Variant for :pkg builds (optional)
├── README.md
└── LICENSE
Labels Reference
io.daemonless.* Labels
| Label | Required | Purpose | Example |
|---|---|---|---|
io.daemonless.port |
Yes | 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://radarr.servarr.com/v1/..." |
io.daemonless.upstream-sed |
No | Regex to extract version | "s/.*\"version\":\"\\([^\"]*\\)\".*/\\1/p" |
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
These labels enable automated version checking:
| Label | Purpose | Example |
|---|---|---|
io.daemonless.upstream-mode |
Version check method | "github", "servarr", "pkg" |
io.daemonless.upstream-repo |
GitHub repo (for github mode) | "radarr/Radarr" |
io.daemonless.upstream-url |
Version API URL | "https://radarr.servarr.com/v1/..." |
io.daemonless.upstream-package |
Package name (for npm/pkg) | "n8n" |
io.daemonless.upstream-branch |
Branch to track | "develop" |
Upstream Mode Values
| Mode | Description |
|---|---|
github |
Check GitHub releases |
github_commits |
Track branch commits |
servarr |
Use Servarr update API |
sonarr |
Use Sonarr releases API |
pkg |
Track FreeBSD package version |
npm |
Check npm registry |
ubiquiti |
Check Ubiquiti firmware API |
source |
No upstream tracking |
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):
Containerfile Patterns
Standard Pattern (:latest)
Downloads binaries from upstream for bleeding-edge versions:
ARG BASE_VERSION=15
FROM ghcr.io/daemonless/base:${BASE_VERSION}
ARG PACKAGES="app-package dependency1 dependency2"
ARG VERSION=""
ARG UPSTREAM_URL="https://api.example.com/releases"
ARG UPSTREAM_SED="s/.*\"version\":\"\\([^\"]*\\)\".*/\\1/p"
# OCI Labels (required)
LABEL org.opencontainers.image.title="App Name"
LABEL org.opencontainers.image.description="App description"
LABEL org.opencontainers.image.source="https://github.com/daemonless/app"
LABEL org.opencontainers.image.version="${VERSION}"
# Daemonless Labels (required)
LABEL io.daemonless.port="8080"
LABEL io.daemonless.category="Category"
LABEL io.daemonless.packages="${PACKAGES}"
LABEL io.daemonless.upstream-url="${UPSTREAM_URL}"
LABEL io.daemonless.upstream-sed="${UPSTREAM_SED}"
# Environment
ENV HOME=/config
# Install packages
RUN pkg update && pkg install -y ${PACKAGES} && \
pkg clean -ay && rm -rf /var/cache/pkg/*
# Download and install app
RUN APP_VERSION=${VERSION:-$(fetch -qo - "${UPSTREAM_URL}" | sed -n "${UPSTREAM_SED}" | head -1)} && \
fetch -qo /tmp/app.tar.gz "https://releases.example.com/app-${APP_VERSION}.tar.gz" && \
mkdir -p /app && \
tar xzf /tmp/app.tar.gz -C /app --strip-components=1 && \
rm /tmp/app.tar.gz && \
echo "${APP_VERSION}" > /app/version
# Config directory
RUN mkdir -p /config && chown -R bsd:bsd /config /app
# Copy s6 service files
COPY root/ /
RUN chmod +x /etc/services.d/*/run /etc/cont-init.d/* 2>/dev/null || true
EXPOSE 8080
VOLUME /config
Package Pattern (:pkg)
Uses FreeBSD packages for stable, tested versions:
ARG BASE_VERSION=15
FROM ghcr.io/daemonless/base:${BASE_VERSION}
ARG PKG_NAME=app
ARG PACKAGES="app"
# Labels must match Containerfile (except pkg-specific)
LABEL io.daemonless.pkg-name="${PKG_NAME}"
LABEL io.daemonless.pkg-source="containerfile"
# Install from FreeBSD packages
RUN pkg update && pkg install -y ${PACKAGES} && \
pkg clean -ay && rm -rf /var/cache/pkg/*
# Config directory
RUN mkdir -p /config && chown -R bsd:bsd /config
# Copy s6 service files (may use run.pkg variant)
COPY root/ /
RUN if [ -f /etc/services.d/app/run.pkg ]; then \
mv /etc/services.d/app/run.pkg /etc/services.d/app/run; \
fi && \
chmod +x /etc/services.d/*/run /etc/cont-init.d/* 2>/dev/null || true
EXPOSE 8080
VOLUME /config
Multi-stage Pattern
For Node.js or compiled applications:
ARG BASE_VERSION=15
FROM ghcr.io/daemonless/base:${BASE_VERSION} AS builder
# Install build dependencies
RUN pkg update && pkg install -y node npm python3 gcc ...
WORKDIR /app
# Build application
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
# Runtime stage
FROM ghcr.io/daemonless/base:${BASE_VERSION}
ARG PACKAGES="node"
# Install runtime dependencies only
RUN pkg update && pkg install -y ${PACKAGES} && pkg clean -ay
# Copy built artifacts
COPY --from=builder --chown=bsd:bsd /app /app
COPY root/ /
RUN chmod +x /etc/services.d/*/run /etc/cont-init.d/* 2>/dev/null || true
EXPOSE 8080
VOLUME /config
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:
#!/bin/sh
exec 2>&1
# Wait for dependencies (optional)
# s6-svwait -U /run/s6/services/redis
cd /app
exec s6-setuidgid bsd /app/bin/app --config /config
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>:
#!/bin/sh
echo "[init] Initializing app..."
# Create required directories
mkdir -p /config/data /config/logs
chown -R bsd:bsd /config
# Generate default config if missing
if [ ! -f /config/app.conf ]; then
cp /app/app.conf.default /config/app.conf
chown bsd:bsd /config/app.conf
fi
echo "[init] App initialized"
Readiness Check
For services that need health checks before reporting ready:
#!/bin/sh
exec 2>&1
# Signal readiness when health endpoint responds
s6-ready-when "curl -sf http://localhost:8080/health" &
cd /app
exec s6-setuidgid bsd /app/bin/app
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:
# Podman logs (live)
podman logs -f radarr
# s6 logs (rotated files)
tail -f /data/config/radarr/logs/daemonless/radarr/current
# Application logs
ls /data/config/radarr/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:
cd daemonless/radarr
# Build all variants (latest, pkg, pkg-latest)
dbuild build
# Build a specific variant
dbuild build --variant latest
# Build and run tests
dbuild build --variant latest
dbuild test --variant latest
CI/CD Pipeline
Standard .woodpecker.yaml for a new image repository:
steps:
- name: pipeline
image: ghcr.io/daemonless/base:15
environment:
GITHUB_TOKEN: { from_secret: GITHUB_TOKEN }
GITHUB_ACTOR: { from_secret: GITHUB_USER }
commands:
- dbuild ci-run --prepare
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 Containerfile, .daemonless/config.yaml, and .woodpecker.yaml.
3. Configure Containerfile
Required labels for dbuild to function correctly:
LABEL io.daemonless.port="8080"
LABEL io.daemonless.category="Utilities"
LABEL io.daemonless.packages="${PACKAGES}"
4. Create Service Files
5. Test Locally
6. Push to Registry
git add .
git commit -m "Initial myapp container"
git remote add origin git@github.com:daemonless/myapp.git
git push -u origin main
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
- [ ] Keep Containerfile and Containerfile.pkg labels in sync
- [ ] Use ARG for BASE_VERSION, PACKAGES, VERSION
- [ ] Include upstream-url and upstream-sed for version detection
- [ ] Add
io.daemonless.wip="true"for incomplete images
Troubleshooting
.NET Apps Won't Start
Ensure ocijail is patched and annotation is set:
See ocijail Patch 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: