🏭 poudriere: Build Your Own Packages, Properly

Gloves made once, in a clean workshop, and worn on every machine you own. Build a custom package repository in throwaway jails and install from it with plain pkg

Video: poudriere (FreeBSD video)
Prefer to watch? This short video runs this page's main commands on a real FreeBSD 15.0 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.

Why not just build on the host?

On the ports page we built nginx straight on the machine that would run it. It worked, and it is how most people start. It also has costs, and you saw several of them:

poudriere (say "poo-dree-air") is the tool the FreeBSD project itself uses to build its official packages (from the documentation), and you can use it too. It builds your chosen ports inside throwaway jails from a clean starting point, and puts the results into a package repository: a plain folder that pkg understands. Then every machine you own installs those packages with pkg install, exactly as it would from the official repository, with no compiler involved.

In mittens-and-gloves terms: ports on the host are making gloves at the kitchen table. poudriere is the glove workshop: made once, to your pattern, and worn on every hand.

ports tree  +  your options  +  a clean build jail
                   |
          poudriere bulk (builds each port in a fresh jail)
                   |
          a package repository (a folder of .pkg files)
                   |
        pkg install on every machine (plain mittens-style install)

Everything below was run for real on FreeBSD 15.0-RELEASE in a small virtual machine (2 processors, 2 GB of memory), with the ZFS layout from the other pages.

Step 1: install and configure

root@practice:~ # pkg install -y poudriere 2>&1 | grep -E "poudriere|Number of"
	poudriere: 3.4.8 [FreeBSD-ports]
Number of packages to be installed: 3
[1/3] Fetching poudriere-3.4.8: .......... done
[3/3] Installing poudriere-3.4.8...
[3/3] Extracting poudriere-3.4.8: .......... done

poudriere is itself a package (about 1 MB, plus a list of release manifests). Its settings live in /usr/local/etc/poudriere.conf, and ours is short:

Step 2: a build jail

poudriere builds in a jail that holds a clean copy of a FreeBSD release. The jail's FreeBSD version matters: packages are built for that release, so the jail should match (or be older than) the machines that will install them (from the documentation, not run here). Normally poudriere downloads the release for you. We have the installation disc, so we used its base.txz and avoided a download:

root@practice:~ # cp /mnt/usr/freebsd-dist/base.txz /var/tmp/
root@practice:~ # ls -lh /var/tmp/base.txz
-rw-r--r--  1 root wheel  157M Oct  6 11:57 /var/tmp/base.txz
root@practice:~ # poudriere jail -c -j j15 -v 15.0-RELEASE -m tar=/var/tmp/base.txz
[00:00:00] Creating j15 fs at /usr/local/poudriere/jails/j15... done
[00:00:00] Installing 15.0-RELEASE amd64 from /var/tmp/base.txz ... done
[00:00:09] Recording filesystem state for clean... done
[00:00:09] Jail j15 15.0-RELEASE amd64 is ready to be used
root@practice:~ # poudriere jail -l
JAILNAME VERSION      ARCH  METHOD TIMESTAMP           PATH
j15      15.0-RELEASE amd64 tar    2026-10-06 11:57:38 /usr/local/poudriere/jails/j15

poudriere jail -c -j j15 -v 15.0-RELEASE -m tar=FILE created a jail named j15 from the archive, in 9 seconds. (Without -m tar= it fetches the release over the network: from the documentation, not run here.) poudriere jail -l lists the jails. It also recorded the clean state of the filesystem ("Recording filesystem state for clean"), so every build can start from it.

Step 3: a ports tree

root@practice:~ # poudriere ports -c -p default -m null -M /usr/ports
[00:00:00] Imported ports tree "default" from /usr/ports
root@practice:~ # poudriere ports -l
PORTSTREE METHOD TIMESTAMP           PATH
default   null   2026-10-06 11:57:47 /usr/ports
root@practice:~ # zfs list -r -o name,used,mountpoint zroot/poudriere
NAME                            USED  MOUNTPOINT
zroot/poudriere                 375M  /usr/local/poudriere
zroot/poudriere/data            688K  /usr/local/poudriere/data
zroot/poudriere/data/.m          96K  /usr/local/poudriere/data/.m
zroot/poudriere/data/cache       96K  /usr/local/poudriere/data/cache
zroot/poudriere/data/images      96K  /usr/local/poudriere/data/images
zroot/poudriere/data/logs        96K  /usr/local/poudriere/data/logs
zroot/poudriere/data/packages    96K  /usr/local/poudriere/data/packages
zroot/poudriere/data/wrkdirs     96K  /usr/local/poudriere/data/wrkdirs
zroot/poudriere/jails           374M  /usr/local/poudriere/jails
zroot/poudriere/jails/j15       374M  /usr/local/poudriere/jails/j15
zroot/poudriere/ports            96K  /usr/local/poudriere/ports

poudriere keeps named ports trees. poudriere ports -c -p default -m null -M /usr/ports registered our existing /usr/ports as the tree called default (-m null means "use this directory, do not fetch anything"). Normally you let poudriere fetch and update its own tree with -m git and poudriere ports -u (from the documentation, not run here). The zfs list that follows shows the datasets poudriere made: the jail (374 MB), and places for the packages, logs and work areas.

A reminder from the ports page: our tree is from November 2025, so the packages we build are older than the official ones (you will see nginx 1.28.0 against the official 1.30.6). On a real machine, keep the tree fresh.

Step 4: your options and your package list

root@practice:~ # cat > /usr/local/etc/poudriere.d/make.conf <<'EOF'
> www_nginx_UNSET=MAIL MAIL_SSL HTTP_DAV HTTP_FLV HTTP_MP4 HTTP_RANDOM_INDEX HTTPV3 WWW
> www_nginx_SET=HTTP_DEGRADATION
> EOF
root@practice:~ # cat > /usr/local/etc/poudriere.d/pkglist <<'EOF'
> www/nginx
> sysutils/tree
> EOF
root@practice:~ # ls /usr/local/etc/poudriere.d
hooks		jails		make.conf	pkglist		ports

Step 5: look before you build

root@practice:~ # poudriere bulk -n -j j15 -p default -f /usr/local/etc/poudriere.d/pkglist
[00:00:00] [Dry Run] Creating the reference jail... done
[00:00:01] [Dry Run] Mounting ports from: /usr/ports
[00:00:01] [Dry Run] Appending to make.conf: /usr/local/etc/poudriere.d/make.conf
[00:00:04] [Dry Run] Gathering ports metadata
[00:00:04] [Dry Run] Calculating ports order and dependencies
[j15-default] [2026-10-06_11h57m56s] [load_priorities] Queued: 23 Built: 0  Failed: 0  Skipped: 0  Ignored: 0  Fetched: 0  Tobuild: 23  Time: 00:00:01
[00:00:05] [Dry Run] Would build 23 packages using 2 builders
[00:00:05] [Dry Run] Ports to build: converters/libiconv converters/p5-Text-Unidecode devel/autoconf devel/autoconf-switch devel/automake devel/gettext-runtime devel/gettext-tools devel/gmake devel/libtextstyle devel/libtool devel/m4 devel/p5-Locale-gettext devel/p5-Locale-libintl devel/pcre2 devel/pkgconf lang/perl5.42 misc/help2man ports-mgmt/pkg print/indexinfo print/texinfo sysutils/tree textproc/p5-Unicode-EastAsianWidth www/nginx

poudriere bulk -n is a dry run: it does everything except build. It sets up the jail, loads our make.conf, works out the dependencies and says what it would do: build 23 packages using 2 builders. We asked for two ports. The other 21 are everything they need, from perl5 and autoconf to pcre2. (The long Ports to build line is the real output: scroll sideways to read it.) Run a dry run first whenever you change the list or the options: it takes five seconds and shows you the size of the job.

Step 6: build

root@practice:~ # time poudriere bulk -j j15 -p default -f /usr/local/etc/poudriere.d/pkglist
[00:00:00] Creating the reference jail... done
[00:00:00] Mounting ports from: /usr/ports
[00:00:00] Appending to make.conf: /usr/local/etc/poudriere.d/make.conf
[00:00:03] Calculating ports order and dependencies
[00:00:04] Building 23 packages using up to 2 builders
[00:00:04] Hit CTRL+t at any time to see build progress and stats
[00:02:49] [01] [00:02:45] Finished ports-mgmt/pkg | pkg-2.4.2: Success
[00:02:55] [01] [00:00:06] Finished print/indexinfo | indexinfo-0.3.1_1: Success
[00:04:36] [01] [00:01:41] Finished devel/gettext-runtime | gettext-runtime-0.23.1: Success
[00:06:34] [01] [00:01:58] Finished devel/libtextstyle | libtextstyle-0.23.1: Success
[00:10:24] [02] [00:07:35] Finished lang/perl5.42 | perl5-5.42.0_1: Success
[00:12:04] [02] [00:01:40] Finished devel/gmake | gmake-4.4.1: Success
[00:12:11] [01] [00:05:37] Finished devel/gettext-tools | gettext-tools-0.23.1_1: Success
[00:12:15] [01] [00:00:04] Finished devel/p5-Locale-gettext | p5-Locale-gettext-1.07: Success
[00:12:18] [01] [00:00:03] Finished converters/p5-Text-Unidecode | p5-Text-Unidecode-1.30: Success
[00:12:20] [01] [00:00:02] Finished textproc/p5-Unicode-EastAsianWidth | p5-Unicode-EastAsianWidth-12.0: Success
[00:12:25] [01] [00:00:05] Finished misc/help2man | help2man-1.49.3_1: Success
[00:12:26] [01] [00:00:01] Finished devel/autoconf-switch | autoconf-switch-20220527: Success
[00:12:40] [01] [00:00:14] Finished devel/pkgconf | pkgconf-2.4.3,1: Success
[00:12:41] [02] [00:00:36] Finished converters/libiconv | libiconv-1.17_1: Success
[00:12:44] [01] [00:00:04] Finished sysutils/tree | tree-2.2.1: Success
[00:12:48] [02] [00:00:07] Finished devel/p5-Locale-libintl | p5-Locale-libintl-1.35: Success
[00:14:10] [01] [00:01:22] Finished print/texinfo | texinfo-7.1_12,1: Success
[00:15:28] [01] [00:01:18] Finished devel/m4 | m4-1.4.19_1,1: Success
[00:15:42] [01] [00:00:14] Finished devel/autoconf | autoconf-2.72: Success
[00:17:00] [02] [00:01:32] Finished devel/libtool | libtool-2.5.4_1: Success
[00:17:07] [01] [00:01:25] Finished devel/automake | automake-1.17: Success
[00:19:30] [01] [00:02:22] Finished devel/pcre2 | pcre2-10.46: Success
[00:21:05] [01] [00:01:35] Finished www/nginx | nginx-1.28.0_3,3: Success
[00:21:05] Creating pkg repository
[00:21:06] Committing packages to repository: /usr/local/poudriere/data/packages/j15-default/.real_1791289153 via .latest symlink
[00:21:06] Cleaning up
     1266.12 real      1579.61 user       226.23 sys

(The output is shortened: setup lines and the repository progress bars are left out.) What you are looking at:

If a package fails, poudriere does not stop: it builds everything else it can, and lists the failures at the end, with a log file for each (from the documentation, not run here).

What you have after a build

root@practice:~ # ls /usr/local/poudriere/data/packages/j15-default
.buildname		.latest			All			data.pkg		logs			meta.conf		packagesite.tzst
.jailversion		.real_1791289153	Latest			data.tzst		meta			packagesite.pkg
root@practice:~ # ls /usr/local/poudriere/data/packages/j15-default/All
autoconf-2.72.pkg			gmake-4.4.1.pkg				libtool-2.5.4_1.pkg			p5-Text-Unidecode-1.30.pkg		pkgconf-2.4.3,1.pkg
autoconf-switch-20220527.pkg		help2man-1.49.3_1.pkg			m4-1.4.19_1,1.pkg			p5-Unicode-EastAsianWidth-12.0.pkg	texinfo-7.1_12,1.pkg
automake-1.17.pkg			indexinfo-0.3.1_1.pkg			nginx-1.28.0_3,3.pkg			pcre2-10.46.pkg				tree-2.2.1.pkg
gettext-runtime-0.23.1.pkg		libiconv-1.17_1.pkg			p5-Locale-gettext-1.07.pkg		perl5-5.42.0_1.pkg
gettext-tools-0.23.1_1.pkg		libtextstyle-0.23.1.pkg			p5-Locale-libintl-1.35.pkg		pkg-2.4.2.pkg
root@practice:~ # du -sh /usr/local/poudriere/data/packages/j15-default
 42M	/usr/local/poudriere/data/packages/j15-default

This folder is a package repository: All/ holds the 23 .pkg files, and the other files (packagesite.pkg, meta.conf, data.pkg) are the catalogue that pkg reads. 42 MB for everything. There is nothing else to install on a server: any web server (or even a file path) can serve it.

And now the part that makes poudriere pleasant:

root@practice:~ # time poudriere bulk -j j15 -p default -f /usr/local/etc/poudriere.d/pkglist 2>&1 | tail -9
[00:00:04] Committing packages to repository: /usr/local/poudriere/data/packages/j15-default/.real_1791289191 via .latest symlink
[00:00:04] Removing old packages
[j15-default] [2026-10-06_12h19m46s] [committing] Queued: 0  Built: 0  Failed: 0  Skipped: 0  Ignored: 0  Fetched: 0  Tobuild: 0   Time: 00:00:02
[00:00:04] Logs: /usr/local/poudriere/data/logs/bulk/j15-default/2026-10-06_12h19m46s
[00:00:05] Cleaning up
j15-default: removed
j15-default-n: removed
[00:00:05] Unmounting file systems
        5.05 real         3.76 user         1.72 sys

Run the same build again and it takes 5 seconds: Queued: 0 Built: 0. poudriere noticed that every package is already up to date, and built nothing. When you update the ports tree or change an option, it rebuilds only the packages that changed, and the ones that depend on them (the second part is from the documentation, not run here). That is why a regular poudriere build server is quick after the first long run.

Using the repository

A machine learns about a repository from a small file in /usr/local/etc/pkg/repos/. First, a small trap:

root@practice:~ # cat > /usr/local/etc/pkg/repos/poudriere.conf <<'EOF'
> poudriere: {
>   url: "file:///usr/local/poudriere/data/packages/j15-default",
>   signature_type: "none",
>   enabled: yes
> }
> FreeBSD-ports: { enabled: no }
> FreeBSD-ports-kmods: { enabled: no }
> EOF
-su: cannot create /usr/local/etc/pkg/repos/poudriere.conf: No such file or directory

The folder /usr/local/etc/pkg/repos does not exist on a new system, and the shell refused to create the file: No such file or directory. We had not noticed it at first, and the next steps quietly carried on without our repository (you will see where, in a moment). Make the folder first:

root@practice:~ # ls /usr/local/etc/pkg/repos
ls: /usr/local/etc/pkg/repos: No such file or directory
root@practice:~ # mkdir -p /usr/local/etc/pkg/repos
root@practice:~ # cat > /usr/local/etc/pkg/repos/poudriere.conf <<'EOF'
> poudriere: {
>   url: "file:///usr/local/poudriere/data/packages/j15-default",
>   signature_type: "none",
>   enabled: yes
> }
> FreeBSD-ports: { enabled: no }
> FreeBSD-ports-kmods: { enabled: no }
> EOF
root@practice:~ # pkg update -f 2>&1 | tail -4
Fetching data: .. done
Processing entries: ... done
poudriere repository update completed. 23 packages processed.
All repositories are up to date.
root@practice:~ # pkg -vv | sed -n '/^Repositories:/,$p'
  [... the two official repositories are shown with "enabled : no" ...]
  poudriere: { 
    url             : "file:///usr/local/poudriere/data/packages/j15-default",
    enabled         : yes,
    priority        : 0
  }
root@practice:~ # pkg rquery '%n-%v' nginx
nginx-1.28.0_3,3

Installing it on another system (a jail)

Our jail from the jails page plays the part of "another machine". It has the official nginx 1.30.6, and we want it to have the poudriere build instead. Our first attempt used the -j option, the way we did on earlier pages:

root@practice:~ # pkg -j web install -y nginx 2>&1 | grep -E "INSTALLED|nginx|pcre2|Number|REINSTALLED|UPGRADED|DOWNGRADED"
New packages to be INSTALLED:
	nginx: 1.30.6,3 [FreeBSD-ports]
Number of packages to be installed: 1
[web.example.org] [1/1] Installing nginx-1.30.6,3...

It installed the official 1.30.6 again, from [FreeBSD-ports]. Why? pkg -j web runs pkg inside the jail and uses the jail's own repository settings (which still point at the official repository). Our new file is on the host. The option that uses the host's configuration on a folder is -r (root directory):

root@practice:~ # service -j web nginx stop
Stopping nginx.
Waiting for PIDS: 9616.
root@practice:~ # pkg -r /jails/web delete -y nginx 2>&1 | tail -3
The operation will free 2 MiB.
[1/1] Deinstalling nginx-1.30.6,3...
[1/1] Deleting files for nginx-1.30.6,3: .......... done
root@practice:~ # pkg -r /jails/web install -y nginx 2>&1 | grep -E "INSTALLED|nginx|pcre2|Number|REINSTALLED|UPGRADED|DOWNGRADED|REMOVED"
New packages to be INSTALLED:
	nginx: 1.28.0_3,3 [poudriere]
Number of packages to be installed: 1
[1/1] Installing nginx-1.28.0_3,3...
[1/1] Extracting nginx-1.28.0_3,3: .......... done
Message from nginx-1.28.0_3,3:
https://www.nginx.com/blog/dynamic-modules-nginx-1-9-11/ and
http://nginx.org/en/docs/ngx_core_module.html#load_module for
/usr/local/libexec/nginx.
root@practice:~ # service -j web nginx start 2>&1 | tail -2
nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful
Starting nginx.
root@practice:~ # jexec web /usr/local/sbin/nginx -V 2>&1 | tr ' ' '\n' | grep -E 'dav|flv|mp4|degradation|v3|mail_ssl'
--with-http_degradation_module
root@practice:~ # pkg -r /jails/web info nginx | grep -E 'Version|HTTP_DAV|HTTP_DEGRADATION|^WWW '
Version        : 1.28.0_3,3
WWW            : https://nginx.com/
	HTTP_DAV       : off
	HTTP_DAV_EXT   : off
	HTTP_DEGRADATION: on
root@practice:~ # fetch -qo - http://10.0.2.50/ | head -6
fetch: http://10.0.2.50/: Not Found
root@practice:~ # ls -l /jails/web/usr/local/sbin/nginx
-r-xr-xr-x  1 root wheel 1056096 Oct  6 12:19 /jails/web/usr/local/sbin/nginx

Now the plan works: nginx: 1.28.0_3,3 [poudriere], and the proof is in the checks:

Notice what did not happen on this machine: no compiler ran, and no build tools were installed. The jail installed a finished package. That is the whole idea: build once in the workshop, install everywhere with plain pkg.

For real machines, put the repository on a web server instead of a folder, and point the other machines at it:

poudriere: {
  url: "https://pkg.example.org/j15-default",
  signature_type: "pubkey",
  pubkey: "/usr/local/etc/ssl/poudriere.pub",
  enabled: yes
}

(An example, from the documentation, not run here: the address and the key are made up.)

Tidy up and keep going

root@practice:~ # poudriere pkgclean -n -j j15 -p default -f /usr/local/etc/poudriere.d/pkglist
[00:00:00] Warning: Will delete anything not listed. To delete listed use -C.
[00:00:04] Looking for unneeded packages
[00:00:04] No stale packages to cleanup
root@practice:~ # zfs list -o name,used zroot/poudriere zroot/poudriere/jails/j15 zroot/poudriere/data/packages
NAME                            USED
zroot/poudriere                 420M
zroot/poudriere/data/packages  42.0M
zroot/poudriere/jails/j15       374M

The routine from here is short: update the ports tree, run poudriere bulk (it rebuilds only what changed), and on each machine run pkg update and pkg upgrade.

Things to try next

All of these are from the documentation, not run here:

⚠️ Do not skip the signing for machines you do not fully control. An unsigned repository over HTTP means anybody in between could hand your servers anything. Sign the repository, check the signature on every machine, and serve it over HTTPS.

Why do poudriere packages never get into trouble?<br>
They were built in a jail<br>
and came out clean.

🧪 Try it yourself

In a FreeBSD virtual machine with ZFS (and a ports tree in /usr/ports):

1. Install poudriere and create a poudriere.conf with your pool. 2. Create a build jail with poudriere jail -c (from a release download or a base.txz). 3. Register your ports tree with poudriere ports -c -p default -m null -M /usr/ports. 4. Write a pkglist with one small port, such as sysutils/tree, and run a dry run with poudriere bulk -n. 5. Run the real build. How long did it take? What did it build? 6. Run it again. What does it say? 7. Add a repos/poudriere.conf to /usr/local/etc/pkg/repos (make the folder first) and run pkg update -f. 8. Install the package from your repository and check where it came from.

The cheat sheet

← bhyve: Virtual Machines Built into FreeBSD · ↑ FreeBSD Corner · vm-bhyve: Virtual Machines with Short Commands →