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
Scripts
| Script | Purpose |
|---|---|
scripts/build.sh |
Main build engine (downloaded by CI) |
scripts/local-build.sh |
Local development builds |
scripts/build-ocijail.sh |
Patch ocijail for mlock support |
scripts/check-upstream-dynamic.sh |
Check for upstream updates |
Local Building
# Build specific image
./scripts/local-build.sh 15 radarr latest
./scripts/local-build.sh 15 radarr pkg
./scripts/local-build.sh 15 radarr pkg-latest
# Build all images
./scripts/local-build.sh 15
# Build with options
./scripts/local-build.sh 15 radarr latest --push
Tag Strategy
| Tag | Source | Packages | Use Case |
|---|---|---|---|
:latest |
Upstream binaries | FreeBSD latest | Bleeding edge |
:pkg |
Containerfile.pkg | FreeBSD quarterly | Stable |
:pkg-latest |
Containerfile.pkg | FreeBSD latest | Middle ground |
:<version> |
Auto-generated | Matches :latest | Pinned version |
CI/CD Pipeline
Standard .woodpecker.yml:
steps:
- name: build-latest
image: /bin/sh
environment:
GITHUB_TOKEN: { from_secret: GITHUB_TOKEN }
GITHUB_ACTOR: { from_secret: GITHUB_USER }
commands:
- |
mkdir -p scripts
fetch -qo scripts/build.sh \
"https://raw.githubusercontent.com/daemonless/daemonless/build-v1.1.1/scripts/build.sh"
chmod +x scripts/build.sh
./scripts/build.sh \
--doas \
--registry ghcr.io \
--image ghcr.io/daemonless/<app> \
--containerfile Containerfile \
--tag latest \
--tag-version \
--skip-wip \
--login --push
- name: build-pkg
# Same but with Containerfile.pkg, --base-version 15-quarterly, --tag pkg
- name: build-pkg-latest
# Same but with Containerfile.pkg, --base-version 15, --tag pkg-latest
when:
- event: push
branch: main
path:
exclude: ["*.md", "docs/**", "LICENSE", ".gitignore"]
- event: manual
Adding a New Image
1. Create Repository
2. Create Containerfile
Use the standard pattern above. Required labels:
LABEL io.daemonless.port="8080"
LABEL io.daemonless.category="Utilities"
LABEL io.daemonless.packages="${PACKAGES}"
3. Create Service Files
Create root/etc/services.d/myapp/run:
4. Create Containerfile.pkg
If a FreeBSD package exists, sync all labels from Containerfile and add:
5. Create .woodpecker.yml
Copy from an existing image and update the image name.
6. Test Locally
# Build
doas podman build -t localhost/myapp:test -f Containerfile .
# Run
doas podman run --rm -it -p 8080:8080 localhost/myapp:test
# Check logs
doas podman logs myapp
7. 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: