I’ve heard about and thought about proxies for pkg servers for some time. Perhaps years, but definitely months. I never took action until I saw this commit in today’s FreshPorts new ports email:
ports-mgmt/git-proxy
Caching proxy for package registries, including FreeBSD pkg
Today. This morning, during coffee at my local shop, I implemented one in my basement. The following shows you how I did it.
Every host in the basement fetches packages: base from pkg.FreeBSD.org and everything else from my own poudriere repo on fedex. The proxy now sits between them and those repos.
git-proxy caches package registries. It speaks the pkg(8) repository protocol, keeps local copies of what it serves, and keeps serving when the upstream can’t be reached. It hands repository catalogues back byte for byte, so pkg still verifies signatures with the same keys as before. It also proxies npm, PyPI, Cargo, OCI images and more, but I only care about pkg here.
The jail and the dataset
The proxy runs in a new jail, proxy01, on r730-01.
First, I created the jail (this is just one approach, there are many others):
[12:47 r730-01 dvl ~] % sudo mkjail create -j proxy01 -v 15.1-RELEASE
Creating data02/jails/proxy01...
Updating FreeBSD-base repository catalogue...
Fetching meta.conf: 100% 179 B 0.2 kB/s 00:01
Fetching data: 100% 82 KiB 84.4 kB/s 00:01
Processing entries: 100%
FreeBSD-base repository update completed. 509 packages processed.
FreeBSD-base is up to date.
The following 212 package(s) will be affected (of 0 checked):
New packages to be INSTALLED:
FreeBSD-acct: 15.1p4 [FreeBSD-base]
FreeBSD-acpi: 15.1 [FreeBSD-base]
FreeBSD-apm: 15.1 [FreeBSD-base]
...
[211/212] Installing FreeBSD-set-optional-15.1...
[212/212] Installing FreeBSD-set-base-15.1...
==> Running trigger: mandoc.ucl
Copying in default flavor...
Now put something like the following in /etc/jail.conf:
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown jail";
exec.clean;
mount.devfs;
path = /jails/$name;
securelevel = 2;
proxy01 {
host.hostname = "proxy01";
ip4.addr = 127.1.0.201;
persist;
}
and then you can start the jail like so:
# service jail start proxy01
The IP address given above is an example provided by mkjail.
The cache itself lives on its own dataset. On the host, I did this:
[r730-01 dvl ~] % sudo zfs create data01/proxy01 [r730-01 dvl ~] % sudo zfs set mountpoint=/jails/proxy01/var/db/proxy01 data01/proxy01
Inside the jail, that’s /var/db/proxy01. Remember that path; it comes back later.
In the jail:
# pkg install git-proxy ... New packages to be INSTALLED: git-proxy: 0.11.0 [local] Number of packages to be installed: 1 The process will require 43 MiB more space. 12 MiB to be downloaded. ... # cp /usr/local/etc/git-proxy/config.yaml.sample /usr/local/etc/git-proxy/config.yaml
Read the pkg-message output (it is not shown here). It explains the URL mapping more clearly than I do below.
The configuration
This is /usr/local/etc/git-proxy/config.yaml.
- 10.55.0.58 is the IP address of this jail.
- fedex.int.unixathome.org is the name of my package server.
- Technically speaking, my package server is pkg01 and fedex is just the name my hosts/jails use to get there – it is a proxy.
The file:
# Listen on the jail's address only. The proxy has no authentication.
listen: "10.55.0.58:8080"
# The URL clients put in their pkg repo files.
base_url: "http://proxy01.int.unixathome.org:8080"
# Keep packagesite/data/meta so `pkg update` still works when
# pkg.FreeBSD.org or fedex is unreachable.
cache_metadata: true
metadata_ttl: "5m"
# data01/proxy01 is mounted at /var/db/proxy01 inside the jail.
# rc.conf: git_proxy_dbdir="/var/db/proxy01"
storage:
url: "file:///var/db/proxy01/artifacts"
max_size: "250GB"
retention:
default: "90d"
database:
driver: "sqlite"
path: "/var/db/proxy01/proxy.db"
log:
level: "info"
format: "text"
ui_request_sources: true
ui_failure_details: true
upstream:
allow_private_hosts:
- "fedex.int.unixathome.org"
freebsd:
trusted: "https://pkg.freebsd.org"
fedex: "http://fedex.int.unixathome.org/packages"
cache_delete: false
mirror_api: false
Each entry under upstream.freebsd is a name and a base URL. Clients reach it at /freebsd/<name>/<path> on the proxy, and the proxy fetches <base URL>/<path>. The names are just labels. Listing any entry replaces the built-in default, so pkg.FreeBSD.org has to be listed explicitly.
The proxy has no authentication of its own, and every ecosystem it supports is on. Anyone who can reach the port can make it fetch from npm, PyPI, and the rest. That’s why it listens on the jail’s LAN address and nothing else. Leave cache_delete and mirror_api off: both are unauthenticated endpoints.
Gotcha #1: the database directory
# sysrc git_proxy_enable=YES # service git_proxy start
It didn’t start. From /var/log/messages:
Nov 4 13:31:44 proxy01 git-proxy[23439]: time=2026-11-04T13:31:44.217Z level=ERROR msg="failed to create server" error="opening database: opening database: unable to open database file (14)"
SQLite error 14 means it can’t open the file. The rc script runs the proxy as www. Before each start, it creates git_proxy_dbdir and gives it to www. That variable defaults to /var/db/git-proxy. My dataset is at /var/db/proxy01, which was still owned by root, so www couldn’t create the database there.
# sysrc git_proxy_dbdir sysrc: unknown variable 'git_proxy_dbdir' # sysrc git_proxy_dbdir=/var/db/proxy01 # service git_proxy restart
That fixed it. git_proxy_dbdir has to match storage.url and database.path in the config.
Something that confused me along the way: daemon(8) runs with -r, so it kept restarting the proxy after each failure. The supervisor shows up in ps even when the proxy itself isn’t running.
Pointing the clients at the proxy
This is empty, before the change:
[13:42 empty dvl ~] % pkg repos -e
FreeBSD-base: {
url : "pkg+https://pkg.FreeBSD.org/FreeBSD:15:amd64/base_release_1",
enabled : yes,
priority : 0,
mirror_type : "SRV",
signature_type : "FINGERPRINTS",
fingerprints : "/usr/share/keys/pkgbase-15"
}
local: {
url : "http://fedex.int.unixathome.org/packages/FreeBSD:15:amd64",
enabled : yes,
priority : 0,
signature_type : "PUBKEY",
pubkey : "/etc/ssl/slocum.unixathome.org.cert"
}
I kept a copy of the old files, then edited them:
[13:46 empty dvl /usr/local/etc/pkg/repos] % sudo mkdir before-proxy01 [13:46 empty dvl /usr/local/etc/pkg/repos] % sudo cp -p FreeBSD-base.conf local.conf before-proxy01 [13:46 empty dvl /usr/local/etc/pkg/repos] % sudoedit FreeBSD-base.conf local.conf
/usr/local/etc/pkg/repos/FreeBSD-base.conf:
FreeBSD-base: {
url: "http://proxy01.int.unixathome.org:8080/freebsd/trusted/${ABI}/base_release_${VERSION_MINOR}",
mirror_type: "none",
enabled: yes
}
/usr/local/etc/pkg/repos/local.conf:
local: {
url: "http://proxy01.int.unixathome.org:8080/freebsd/fedex/${ABI}",
signature_type: "pubkey",
pubkey: "/etc/ssl/slocum.unixathome.org.cert",
enabled: yes
}
Drop the pkg+ prefix and set mirror_type to "none", because the proxy has no SRV records. Signature settings stay as they were. The fingerprints for FreeBSD-base still come from /etc/pkg/FreeBSD.conf.
Gotcha #2: Bad Gateway for my own repo
In the following, you’ll see a “has a wrong packagesite” message. It is normal in this case. The data it has on hand is not from the repos now specified in the configuration. Well it is, but the names have changed, so a download will occur. pkg prints it once per repo and it’s harmless.
[13:47 empty dvl /usr/local/etc/pkg/repos] % sudo pkg update -f Updating FreeBSD-base repository catalogue... pkg: Repository FreeBSD-base has a wrong packagesite, need to re-create database [empty.int.unixathome.org] Fetching meta.conf: 100% 179 B 0.2 kB/s 00:01 [empty.int.unixathome.org] Fetching data: 100% 82 KiB 84.4 kB/s 00:01 Processing entries: 100% FreeBSD-base repository update completed. 509 packages processed. Updating local repository catalogue... pkg: Repository local has a wrong packagesite, need to re-create database pkg: http://proxy01.int.unixathome.org/freebsd/fedex/FreeBSD:15:amd64/meta.txz: Bad Gateway repository local has no meta file, using default settings pkg: http://proxy01.int.unixathome.org/freebsd/fedex/FreeBSD:15:amd64/data.pkg: Bad Gateway ... Unable to update repository local Error updating repositories!
pkg leaves the :8080 out of those error lines, but the requests did go to the proxy. FreeBSD-base worked, but local didn’t. git-proxy refuses upstreams with private, ULA, CGNAT, or loopback addresses by default. fedex resolves to an RFC 1918 address. The fix is to list it under upstream.allow_private_hosts (it’s in the config above), then restart:
[13:48 empty dvl /usr/local/etc/pkg/repos] % sudo pkg update -f Updating FreeBSD-base repository catalogue... [empty.int.unixathome.org] Fetching meta.conf: 100% 179 B 0.2 kB/s 00:01 [empty.int.unixathome.org] Fetching data: 100% 82 KiB 84.4 kB/s 00:01 Processing entries: 100% FreeBSD-base repository update completed. 509 packages processed. Updating local repository catalogue... [empty.int.unixathome.org] Fetching meta.conf: 100% 168 B 0.2 kB/s 00:01 [empty.int.unixathome.org] Fetching data: 100% 367 KiB 375.4 kB/s 00:01 Processing entries: 100% local repository update completed. 1212 packages processed. All repositories are up to date.
And the result:
[14:42 empty dvl ~] % pkg repos -e
FreeBSD-base: {
url : "http://proxy01.int.unixathome.org:8080/freebsd/trusted/FreeBSD:15:amd64/base_release_1",
enabled : yes,
priority : 0,
signature_type : "FINGERPRINTS",
fingerprints : "/usr/share/keys/pkgbase-15"
}
local: {
url : "http://proxy01.int.unixathome.org:8080/freebsd/fedex/FreeBSD:15:amd64",
enabled : yes,
priority : 0,
signature_type : "PUBKEY",
pubkey : "/etc/ssl/slocum.unixathome.org.cert"
}
What gets cached
After a pkg upgrade on empty (eight upgrades and one reinstall, 8 MiB in all), this is the cache:
# find /var/db/proxy01/ /var/db/proxy01/ /var/db/proxy01/proxy.db-shm /var/db/proxy01/proxy.db-wal /var/db/proxy01/artifacts /var/db/proxy01/artifacts/_metadata /var/db/proxy01/artifacts/_metadata/freebsd /var/db/proxy01/artifacts/_metadata/freebsd/08665cadc3e47db3ac91bb6142a86b4a4b2cbd308167c0743a285174f843561b /var/db/proxy01/artifacts/_metadata/freebsd/08665cadc3e47db3ac91bb6142a86b4a4b2cbd308167c0743a285174f843561b/metadata /var/db/proxy01/artifacts/_metadata/freebsd/f96fbdcc3979328354d6e006254e5f9b3f78088041bfc8f162fef49514735483 /var/db/proxy01/artifacts/_metadata/freebsd/f96fbdcc3979328354d6e006254e5f9b3f78088041bfc8f162fef49514735483/metadata /var/db/proxy01/artifacts/_metadata/freebsd/d6c357dcf141a950d64d2521b0ab766e7acb90216af2773b6a11be67cc937954 /var/db/proxy01/artifacts/_metadata/freebsd/d6c357dcf141a950d64d2521b0ab766e7acb90216af2773b6a11be67cc937954/metadata /var/db/proxy01/artifacts/_metadata/freebsd/9c8defb07df528b71ab70ea88028ead1ffbbd428cda5def3b514b13cf6713fa8 /var/db/proxy01/artifacts/_metadata/freebsd/9c8defb07df528b71ab70ea88028ead1ffbbd428cda5def3b514b13cf6713fa8/metadata /var/db/proxy01/proxy.db
The cache holds the catalogues for both repos, so pkg update keeps working when an upstream is down. It holds none of the packages themselves.
git-proxy only caches packages stored under Hashed/, with a content hash in the file name. A package with a plain name like rsync-3.5.1_1.pkg can be rebuilt under the same name, so the proxy passes it through without keeping a copy. pkg.FreeBSD.org is built with hashed names. My repo wasn’t:
% curl -s -o /dev/null -w '%{http_code}\n' http://fedex.int.unixathome.org/packages/FreeBSD:15:amd64/All/Hashed/
404
Turning on PKG_HASH in poudriere
That needs one line in /usr/local/etc/poudriere.conf:
[14:19:11] dvl@pkg01:~ % grep -n PKG_HASH /usr/local/etc/poudriere.conf 292:PKG_HASH=yes
Only newly built packages get hashed names, so every package had to be rebuilt. The build jail was overdue for an update anyway; it was still at 15.0, and 15.0-RELEASE went end-of-life on 1 October 2026:
[13:57:20] dvl@pkg01:~ % sudo poudriere jail -u -j 150amd64 ... WARNING: FreeBSD 15.0-RELEASE HAS PASSED ITS END-OF-LIFE DATE. ... 15.0-RELEASE-p14
After that, poudriere noticed the newer jail and started over by itself:
[14:18:26] dvl@pkg01:/usr/local/etc % sudo poudriere bulk -j 150amd64 -p default -z primary -f /usr/local/etc/poudriere.d/buildlists/primary ... [00:00:10] newer version of jail, cleaning all packages... done ...
That build is running now. When it’s finished, fedex should have an All/Hashed/ directory, and the proxy will start keeping copies of my own packages too. The proxy and client configs don’t need to change.
The poudriere build jail is still on 15.0, and every host is on 15.1. Packages built on 15.0 install fine on 15.1, because they share the FreeBSD:15:amd64 ABI. Kernel modules are another matter, so a 151amd64 jail is next on the list.
After the build
The build has finished. The hashed packages are in All/Hashed/, next to the plain ones:
% curl -s http://fedex.int.unixathome.org/packages/FreeBSD:15:amd64/All/Hashed/ | grep -c 'href=.*\.pkg' 989
The catalogue points every package there, so that is what clients now ask for:
% curl -s -O http://fedex.int.unixathome.org/packages/FreeBSD:15:amd64/packagesite.pkg % tar -xf packagesite.pkg % grep -c '"repopath":"All/Hashed/' packagesite.yaml 989
The proxy and client configs didn’t need to change. Before the rebuild, the cache held 4 files, all catalogues. After mydev upgraded four packages from my repo, it held 9:
# find /var/db/proxy01/artifacts -type f | wc -l
9
The webpage
The webpage looks like this. I could put an nginx proxy in there, and a certificate, so I’m viewing it all over TLS. Perhaps that is a future project.
In the subsequent screenshots, you’ll see I found the dark/light mode button, visible at the top right of this screenshot.

Adding a 2nd host
I converted a 2nd host, ran pkg upgrade, and the page looks like this:

Adding a 3rd host
After upgrading a 3rd host, I see:

Analytics
Here’s the analytics page:

And a bit farther down the page.

Finally…
After I’ve run this at home for a while, I’ll deploy a similar proxy into each remote server. Every one of them runs at least 4 jails, so that’s me dropping the traffic by at least a factor of four, not to mention the speed increases (because the packages are now local) for every update after the first one.
Also, the configuration above shows me caching packages for my local package builder (fedex, see local.conf). You might think that is unnecessary because the package host is on the same network.
It gets worse/better. fedex and proxy01 are jails on the same host. They use different zpools for their data.
That package host (pkg01) is local. On remote hosts, caching packages from fedex is definitely a bandwidth and time saver.
If you’re not building your own packages, then this approach is a time saver, if you install to more than one host/jail.