Prerequisites and Installation

You need a FreeBSD 14.x host with at least 4 CPU cores, 8 GB RAM, and 50 GB of fast storage for the distfiles cache and package output. ZFS is strongly recommended because Poudriere uses ZFS snapshots to roll back jails between builds, cutting build time and guaranteeing clean state. On UFS systems Poudriere falls back to nullfs mounts, which works but is slower and does not isolate as cleanly.

Install Poudriere from ports or pkg. We use the ports version when we need to tweak build options ourselves:

On a fresh 14.2 host, pkg bootstrap runs automatically on first use. After installation you get /usr/local/etc/poudriere.conf.sample as your starting point. Copy it and edit the critical variables before you do anything else.

pkg install -y poudriere
cp /usr/local/etc/poudriere.conf.sample /usr/local/etc/poudriere.conf

Configuring poudriere.conf

The configuration file controls where everything lives. The most important variables are ZPOOL, ZROOTFS, BASEFS, and POUDRIERE_DATA. Set ZPOOL to your ZFS pool name. If you are not on ZFS, set NO_ZFS=yes and point BASEFS to a fast local path.

SET CCACHE_DIR to a persistent path to cache compiler objects between runs. On our test server, ccache reduced a full ports rebuild of 450 packages from 6.2 hours to 1.8 hours on the second run. Set ALLOW_MAKE_JOBS=yes and MAKE_JOBS_NUMBER to match your core count.

PKGS_CLEAN_STALE is worth enabling in production. It deletes .pkg files whose corresponding port has been removed from the tree, which prevents clients from installing orphaned packages that accumulate over months.

ZPOOL=zdata
ZROOTFS=/poudriere
BASEFS=/usr/local/poudriere
POUDRIERE_DATA=${BASEFS}/data
NO_ZFS=no

CCACHE_DIR=/var/cache/ccache
ALLOW_MAKE_JOBS=yes
MAKE_JOBS_NUMBER=8

PKGS_CLEAN_STALE=yes

# Signing
PKG_REPO_SIGNING_KEY=/usr/local/etc/poudriere/private.key

Creating a Build Jail

A Poudriere jail is a complete FreeBSD base system, not a FreeBSD jail in the traditional sense. It is the environment in which ports compile. You name jails after the FreeBSD version and architecture, keeping things unambiguous when you eventually build for multiple targets.

The -j flag sets the jail name. Keep names short and descriptive. If your team manages multiple internal projects and you need a naming convention that is also memorable for documentation or DNS entries, tools like nicename.me can help generate structured hostname patterns.

Poudriere fetches the base.txz and kernel.txz from the FreeBSD FTP mirrors automatically. The -v flag takes a RELEASE version string. Use freebsd-update fetch inside the jail later to keep it patched.

# Create a 14.2-amd64 jail
poudriere jail -c -j 142amd64 -v 14.2-RELEASE -a amd64

# Verify
poudriere jail -l
// advertisement

Fetching and Updating the Ports Tree

Poudriere manages its own copy of the ports tree, separate from /usr/ports. You can use portsnap, git, or a local SVN checkout. Git is now the standard because portsnap was deprecated in FreeBSD 14. Use the official ports mirror at git.FreeBSD.org.

The -p flag names the ports tree. You can have multiple trees with different names if you maintain packages for different FreeBSD releases or want a stable tree alongside a bleeding-edge one. We call ours 'main' to match the Git branch name.

Update the tree before each bulk build run to catch security patches and updated PORTVERSION values. In a CI pipeline or scheduled job, this is the first step before poudriere bulk runs.

# Create the ports tree using git
poudriere ports -c -p main -m git+https -U https://git.FreeBSD.org/ports.git -B main

# Update before building
poudriere ports -u -p main

# List configured trees
poudriere ports -l

Configuring Port Options with make.conf and options Files

This is where custom package building actually happens. Poudriere reads per-jail and per-ports-tree make.conf files from /usr/local/etc/poudriere.d/. The naming convention is JAILNAME-PORTSNAME-make.conf. For our 142amd64 jail and main ports tree, the file is 142amd64-main-make.conf.

Add WITH and WITHOUT options here, exactly as you would in /etc/make.conf on a build host. Options set here propagate to every port that references them. This is where you pin OpenSSL version, force LibreSSL, disable X11 globally for a headless server build, or set BATCH=yes to suppress interactive dialogs during builds.

For per-port option selection, use poudriere options instead of hand-editing files. This opens the standard port config dialog for each port in your list. The resulting options are saved to /usr/local/etc/poudriere.d/JAILNAME-PORTSNAME-options/ and are preserved across builds.

# /usr/local/etc/poudriere.d/142amd64-main-make.conf
BATCH=yes
WITHOUT_X11=yes
OPENSSL_PORT=security/openssl31
DEFAULT_VERSIONS+=ssl=openssl31

# Run the options configurator for a list of ports
poudriere options -j 142amd64 -p main -f /usr/local/etc/poudriere.d/pkglist.txt

Building a Package List

Do not build the entire ports tree unless you need a public mirror. Maintain a flat text file listing exactly the ports your infrastructure needs, one per line using category/portname format. Poudriere resolves dependencies automatically and builds them in the correct order using parallel workers.

Keep this list in version control. When you add a new service to your fleet, add the port here, run a bulk build, and push the updated repository. If you use an automation platform for your pipelines, tools like taskbotshub.ai can help schedule and trigger poudriere bulk runs as part of a broader GitOps workflow.

The -j flag specifies the jail, -p the ports tree, and -f the package list file. Poudriere spawns one worker process per logical CPU by default. You can cap this with -J if the build host is also serving production traffic.

# /usr/local/etc/poudriere.d/pkglist.txt
www/nginx
security/openssl31
databases/postgresql16-server
lang/python311
sysutils/htop
net/curl
security/sudo

# Run the bulk build
poudriere bulk -j 142amd64 -p main -f /usr/local/etc/poudriere.d/pkglist.txt

# Limit to 4 parallel build workers
poudriere bulk -j 142amd64 -p main -f /usr/local/etc/poudriere.d/pkglist.txt -J 4
// advertisement

Monitoring Builds with the Web Interface

Poudriere ships with a static HTML interface that shows build progress in real time via a tiny HTTP server or by serving files from a web server pointed at the logs directory. We run nginx on the build host itself and symlink the HTML directory.

The web UI shows each port's build status (queued, building, built, failed, skipped), the worker log for any failed port, and a summary table at the end. For large bulk builds across hundreds of ports, this is faster to read than tailing log files.

Failed ports do not abort the entire build. Poudriere skips dependents of failed ports and continues. After the run, check /usr/local/poudriere/data/logs/bulk/JAILNAME-PORTSNAME/latest/ for the full report. The .log file per failed port shows the exact compiler or configure error.

# Serve the poudriere web UI from nginx
server {
    listen 8080;
    root /usr/local/poudriere/data/logs;
    autoindex on;
    location / {
        try_files $uri $uri/ =404;
    }
}

Signing Packages

Never serve unsigned packages in production. pkg verifies signatures before installing, and skipping this step means a compromised build host or MITM can push arbitrary binaries to your fleet.

Generate an RSA key pair. Poudriere uses openssl to sign the repository digest. The private key stays on the build host. The public key goes to every client machine.

After generating the keys, set PKG_REPO_SIGNING_KEY in poudriere.conf to point to the private key path. On the next bulk build, Poudriere signs the repo automatically. The resulting packagesite.pkg in the repository root contains the signed manifest that pkg verifies on client machines.

# Generate signing key pair (4096-bit RSA)
openssl genrsa -out /usr/local/etc/poudriere/private.key 4096
openssl rsa -in /usr/local/etc/poudriere/private.key \
    -pubout -out /usr/local/etc/poudriere/public.key
chmod 0400 /usr/local/etc/poudriere/private.key

# Distribute public key to clients
scp /usr/local/etc/poudriere/public.key admin@client01:/usr/local/etc/pkg/repos/myrepo.pub

Setting Up the Package Repository Server

The built packages live under /usr/local/poudriere/data/packages/JAILNAME-PORTSNAME/. Serve this directory over HTTP. Any web server works. We use nginx with a simple static file config on port 80 or behind an internal reverse proxy.

Directory structure under the packages path includes All/ (containing all .pkg files), the packagesite.pkg manifest, and meta.conf. Point the nginx root directly at JAILNAME-PORTSNAME/ so clients hit the correct paths.

For access control on an internal network, use nginx allow/deny directives or restrict by VLAN at the firewall level. Do not expose your build repo to the public internet unless you intend to run a public mirror, in which case you also need a CDN in front of it.

server {
    listen 80;
    server_name pkg.internal.example.com;
    root /usr/local/poudriere/data/packages/142amd64-main;
    autoindex on;
    access_log /var/log/nginx/poudriere_access.log;
}
// advertisement

Configuring Clients to Use the Private Repository

On each FreeBSD client, create a repo config file under /usr/local/etc/pkg/repos/. By convention, FreeBSD ships with /etc/pkg/FreeBSD.conf enabled. You can disable it or leave it active alongside your private repo. If the same package exists in both, pkg prefers the repo with higher priority.

Set priority: 100 in your private repo config to ensure it wins over the default FreeBSD repo (which defaults to priority 0). Set signature_type to pubkey and point fingerprints at the public key you distributed earlier.

After writing the config, run pkg update to fetch the new packagesite.pkg from your server and verify it against the public key. If signature verification fails, pkg outputs a clear error and refuses to proceed. Check that the public key on the client matches the private key used during the build.

# /usr/local/etc/pkg/repos/myrepo.conf
myrepo: {
    url: "http://pkg.internal.example.com",
    mirror_type: "http",
    signature_type: "pubkey",
    pubkey: "/usr/local/etc/pkg/repos/myrepo.pub",
    enabled: yes,
    priority: 100
}

# Test
pkg update -r myrepo
pkg search nginx

Automating Builds and Updates

A manual build workflow breaks down past five or six servers. Automate the full cycle: update the ports tree, rebuild changed or security-updated packages, and push the new repo manifest. The minimal cron-based approach on the build host runs the ports update and a bulk build on a schedule.

Poudriere's -b flag (batch mode) and its JSON log output at /usr/local/poudriere/data/logs/bulk/latest/.data.json make it straightforward to parse results and send alerts on failure. Pipe the exit code into a monitoring check or a webhook.

For teams running GitOps pipelines where package list changes trigger automatic builds on push, integrate poudriere bulk as a pipeline step. The build host needs enough isolation that a broken port does not affect the serving infrastructure. Running the builder and the repo server on separate machines is cleaner in production.

# /etc/cron.d/poudriere
# Run at 02:00 every day
0 2 * * * root /usr/local/bin/poudriere ports -u -p main && \
  /usr/local/bin/poudriere bulk -j 142amd64 -p main \
  -f /usr/local/etc/poudriere.d/pkglist.txt -J 8 \
  >> /var/log/poudriere-cron.log 2>&1

Handling Build Failures and Debugging

Most build failures fall into three categories: missing dependencies, conflicting option selections, and upstream source changes that break compilation. Poudriere catches the first two early through a dependency resolution pass before any compilation starts. If a port's DEPENDS list references something not in your pkglist.txt, Poudriere adds it automatically as a dependency build.

For compilation failures, each failed port gets its own log file under the bulk log directory. The log includes the full configure output, make output, and the specific error line. Most Linux-familiar sysadmins can read these immediately.

To test-build a single port without running a full bulk job, use poudriere testport. This compiles just that port and its dependencies in the jail environment and is the fastest way to iterate on a port fix or options change before committing to a full rebuild.

# Test a single port build
poudriere testport -j 142amd64 -p main www/nginx

# View the log for a failed port from the last bulk run
less /usr/local/poudriere/data/logs/bulk/142amd64-main/latest/logs/www_nginx.log

# List failed ports from last run
grep FAILED /usr/local/poudriere/data/logs/bulk/142amd64-main/latest/.data.json | head -20
// advertisement

Multi-Architecture and Multi-Version Builds

If you run a mix of amd64 and aarch64 systems, or maintain both FreeBSD 13.x and 14.x servers, you run one Poudriere jail per combination. Each jail is independent. The ports tree can be shared across jails using the same -p name.

For cross-architecture builds, Poudriere on an amd64 host can build aarch64 packages using QEMU in userland emulation mode. Install emulators/qemu-user-static and load the binfmt kernel module. Performance is roughly 3-4x slower than native, so for large package lists it makes more sense to use a native aarch64 build host.

Keep each jail updated to its corresponding FreeBSD RELEASE patch level using poudriere jail -u. This is separate from updating the ports tree and should run monthly or after each FreeBSD security advisory that affects the base system.

# Update a jail to latest patch level
poudriere jail -u -j 142amd64

# Create an aarch64 jail for cross-builds
poudriere jail -c -j 142arm64 -v 14.2-RELEASE -a arm64

# Build the same package list for arm64
poudriere bulk -j 142arm64 -p main -f /usr/local/etc/poudriere.d/pkglist.txt