A Private pkg Repo Behind Mutual TLS

May 21, 2026, 6:59 p.m.
A Private pkg Repo Behind Mutual TLS

I am a big fan of mutual TLS ("mTLS" if you prefer the shorter spelling, "client certificates" if you are describing the half a user actually touches). Strangely, I rarely see it used in the wild. That probably says something worrying about how I choose to spend free time, but they are a neat fit for small private infrastructure.

Most people reach for HTTP Basic, an API token, or a VPN, and call it a day. A private pkg repository is one of those quiet little places where mutual TLS fits perfectly: a well established mechanisms, no humans typing passwords, and a server that should only answer questions from boxes I actually have access to.

This is the story of putting a FreeBSD repository over HTTPS, and make nginx accept only clients with certificates signed by my own tiny certificate authority. This can be usefull if you want to for example build a "enterprise repo" where only subscribed user can have access, or test repo that only friends can access.

Start with plain HTTPS

First things first - port 80's only job is to redirect to 443. There is no prize for serving packages over cleartext in 2026. A tiny declaration in /usr/local/etc/nginx/sites-available/ is enough:

server {
        listen 80;
        server_name pkg.example.com;

        return 301 https://$server_name$request_uri;
}

The server side of TLS

Next, the actual HTTPS server. This is still the "regular" half of TLS - the server proves who it is to clients. I am using a Let's Encrypt certificate for pkg.example.com, which is free and renews itself if you ask it nicely. The mutual half comes later - first we need a working one-sided handshake to build on top of. The configuration is well known for all of us:

server {
        listen 443 ssl;
        listen [::]:443 ssl;

        ssl_certificate /usr/local/etc/nginx/ssl/example.com.crt;
        ssl_certificate_key /usr/local/etc/nginx/ssl/example.com.key;
        ssl_protocols TLSv1.2 TLSv1.3;

        root /var/www-private-pkg/html;

        server_name pkg.example.com;

        location / {
                try_files $uri $uri/ =404;
        }
}

The root is where the pkg repo will eventually live, but for the moment all it needs is something to serve so we can confirm the configuration is correct:

mkdir -p /var/www-private-pkg/html
echo "Welcome!" > /var/www-private-pkg/html/index.html

Enable the site the usual way by creating a symlink and reload nginx:

service nginx reload

If everything is wired up correctly, hitting the domain in a browser returns a cheerful "Welcome!". That is the boring half done.

Becoming a tiny CA

Now the mutual-TLS part. In a regular TLS handshake, only the server presents a certificate; the client stays anonymous.

Regular TLS handshake

With mTLS, the client also presents one, and the server checks it against a list of signers it trusts.

Mutual TLS handshake

We want this repository to only respond to clients holding a certificate that we have signed ourselves. That means we need our own little certificate authority - nothing fancy, just enough to sign a handful of client certs.

A 4096-bit private key for the CA:

openssl genrsa -out server.key 4096

And a self-signed root certificate good for ten years. The defaults are mostly fine; the only field that really matters is the Common Name, which I set to the repository's hostname:

$ openssl req -x509 -new -nodes -key server.key -sha256 -days 3650 -out server.crt
You are about to be asked to enter information that will be incorporated
into your certificate request.
[...]
Country Name (2 letter code) [AU]:FR
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Example
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:pkg.example.com
Email Address []:

That server.crt is now the trust anchor for every client certificate I will hand out. Keep server.key somewhere safe; anyone with that file can create certificates that the server will accept.

Minting a client certificate

A client certificate is the same dance, except this time the request gets signed by the CA we just built, instead of being self-signed like the root was.

Generate the client's private key:

openssl genrsa -out oshogbo.key 4096

Build a certificate signing request. Again, the only field doing real work is the Common Name - that becomes the "identity" of this client:

$ openssl req -new -key oshogbo.key -out oshogbo.csr
[...]
Country Name (2 letter code) [AU]:PL
Organization Name (eg, company) [Internet Widgits Pty Ltd]:oshogbo
Common Name (e.g. server FQDN or YOUR name) []:oshogbo

And sign it with the CA:

$ openssl x509 -req -days 365 -in oshogbo.csr \
      -CA ../../server.crt -CAkey ../../server.key -CAcreateserial \
      -out oshogbo.crt
Certificate request self-signature ok
subject=C = PL, ST = Some-State, O = oshogbo, CN = oshogbo

One year of validity feels about right for a client cert. Long enough that I am not rotating them every weekend, short enough that a forgotten laptop eventually stops being a problem.

If a laptop gets lost before then, you do not have to wait for expiry - a certificate revocation list (CRL) handles the rest. The proper openssl ca workflow keeps an index.txt and lets you do openssl ca -revoke oshogbo.crt followed by openssl ca -gencrl -out ca.crl, and nginx picks the list up with a single ssl_crl /usr/local/etc/nginx/ssl/ca.crl; line in the server block. A reload later, the revoked cert no longer gets in.

Flipping nginx into mTLS mode

Back in the HTTPS server block, two lines promote the connection from regular TLS to mutual TLS:

ssl_client_certificate /path/to/ca.crt;
ssl_verify_client on;

The ssl_client_certificate points at the CA certificate - that is the list of signers nginx will trust during the mTLS handshake. ssl_verify_client on means the server hangs up on anyone who cannot prove they hold a private key matching a cert signed by that CA.

Reload nginx, and the repository now requires a client certificate to talk to it at all.

Did it actually work?

The quickest check is to make a request without presenting a client certificate:

$ curl https://pkg.example.com/
<html>
<head><title>400 No required SSL certificate was sent</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>No required SSL certificate was sent</center>
<hr><center>nginx/1.26.2</center>
</body>
</html>

"No required SSL certificate was sent" is exactly the response we wanted. Retrying the same request with the client cert attached:

$ curl --cert clientcert/www-private-pkg/users/oshogbo/oshogbo.crt \
       --key  clientcert/www-private-pkg/users/oshogbo/oshogbo.key \
       https://pkg.example.com/
Welcome!

That is the whole trick - both sides of the handshake now have something to prove. The server still has a perfectly normal Let's Encrypt certificate, so any browser or pkg client knows it is talking to the right host. That is the "regular TLS" half. The "mutual" half is that the server also knows it is talking to a machine we have authorized, because the client had to present a certificate signed by our private CA. No passwords, no tokens, no VPN - just a key file on each machine that needs access.

Filling the repo with Poudriere

So far the repository serves only a Welcome! page, but a pkg client expects .pkg files arranged in a specific directory layout. Poudriere is the FreeBSD-native way to produce that layout: it builds every port in a clean jail at the exact ABI our consumers are running on, and drops the results into a tree that pkg already knows how to consume.

I run Poudriere on a separate build host, not the nginx box. There is no reason the build host has to be reachable from the internet - its only output is a directory of packages that the public host serves afterwards. That is a nice property: the thing doing the heavy lifting never has to listen on a public port.

The jail has to match the ABI of the machines pulling from the repo, otherwise nothing will install. Whenever consumers move to a new branch, I rebuild the jail so the userland and kernel headers used at build time match the ones used at install time.

sudo poudriere jail -c -j 15-stable -b -m src=/usr/src -v 15-stable

I also keep a ports tree updated on the build host:

sudo poudriere ports -u -p default

The actual build is one command, fed a flat text file with one port origin per line:

sudo poudriere bulk -j 15-stable -p default -f PORTS_LIST

-j picks the jail so the resulting packages target the right ABI, -p is the ports tree, and -f is the list of ports to actually build. When the bulk finishes, the packages live under /usr/local/poudriere/data/packages/15-stable-default/.latest/. The .latest symlink is the bit that matters - it always points at the most recent successful build.

Whatever lives under .latest/ is what the nginx box needs to expose under /var/www-private-pkg/html/packages-dev/FreeBSD:15:amd64/. That path is not arbitrary - it is exactly the URL the client config will point at, once ${ABI} is expanded. How the files get there is taste: a sync command, a deploy pipeline, a shared filesystem. The important part is that the layout on the web root matches what pkg(8) expects to fetch.

Teaching pkg to bring its cert

With the repo now full of packages, the real consumer of this repository is pkg(8). The good news is that pkg speaks the same TLS as everything else on the system, and its repository config has an env block that gets exported to the underlying fetch library. Two environment variables are all it needs: SSL_CLIENT_CERT_FILE and SSL_CLIENT_KEY_FILE.

Drop a repo file under /usr/local/etc/pkg/repos/ - for example enterprise.conf:

enterprise: {
  url: "https://pkg.example.com/packages-dev/${ABI}",
  signature_type: "fingerprints",
  fingerprints: "/usr/share/Example/keys/pkg",
  priority: 11,
  enabled: yes,
  env: {
        SSL_CLIENT_CERT_FILE: "/usr/local/etc/pkg/keys/enterprise.crt",
        SSL_CLIENT_KEY_FILE: "/usr/local/etc/pkg/keys/enterprise.key"
  }
}

The ${ABI} placeholder expands at runtime to something like FreeBSD:15:amd64, so the same config works across releases. signature_type: fingerprints is pkg's own check on the package signing key - separate from the transport mTLS, and both layers are worth keeping. Setting priority: 11 puts this repo above the default FreeBSD one when names collide. The env block is the actual mTLS hookup: pkg has no first-class client-cert option, but its underlying fetcher honors these two variables. Stash the files under /usr/local/etc/pkg/keys/, chmod 0600 the key, and pkg update should just work:

# pkg update -f
Updating enterprise repository catalogue...
Fetching meta.conf: . done
Fetching data: ... done
Processing entries: ...... done
enterprise repository update completed. 60 packages processed.
All repositories are up to date.

For peace of mind, the same check that worked with curl works through pkg. Comment out the env block, run pkg update -f again, and the catalog fetch falls over before it gets anywhere:

# pkg update -f
Updating enterprise repository catalogue...
pkg: https://pkg.example.com/packages-dev/FreeBSD:15:amd64/meta.txz: Bad Request
pkg: https://pkg.example.com/packages-dev/FreeBSD:15:amd64/data.pkg: Bad Request
pkg: https://pkg.example.com/packages-dev/FreeBSD:15:amd64/packagesite.pkg: Bad Request
Unable to update repository enterprise
Error updating repositories!

"Bad Request" is nginx's 400 No required SSL certificate was sent.

After that, pkg install behaves exactly like it does against the FreeBSD mirrors - except every byte travels over a connection that the server has already authorized us to open:

# pkg install -y acme-agent
Updating enterprise repository catalogue...
enterprise repository is up to date.
All repositories are up to date.
The following 1 package(s) will be affected (of 0 checked):

New packages to be INSTALLED:
    acme-agent: 1.4.2 [enterprise]

Number of packages to be installed: 1

The process will require 3 MiB more space.
3 MiB to be downloaded.
[1/1] Fetching acme-agent-1.4.2: .......... done
Checking integrity... done (0 conflicting)
[1/1] Installing acme-agent-1.4.2...
[1/1] Extracting acme-agent-1.4.2: ... done

Closing the loop

Putting it all together: Poudriere builds the packages, the build host hands them off to the public host, nginx serves them, mutual TLS gates access, and pkg(8) installs from a repository that only authorized machines can reach. No passwords, no tokens, no VPN - and a TLS layer that is doing real work in both directions for once.