Setting PKG_PATH Before Anything Else

Every pkg_add invocation reads the PKG_PATH environment variable to locate packages. If it is not set, pkg_add will fail or prompt you interactively. Set it in /etc/installurl for system-wide persistence - this is the preferred location since OpenBSD 6.1.

The file contains a single line: the base URL of your chosen mirror. OpenBSD will append the release-specific path automatically, so you do not include the version number.

For interactive sessions, you can also export PKG_PATH directly, but /etc/installurl is the right answer for servers. The CDN mirror at cdn.openbsd.org uses Fastly and resolves to a geographically close node in our experience - we tested from Frankfurt and Tokyo and got sub-50ms connections to appropriate regional mirrors.

# Write your mirror to /etc/installurl
echo 'https://cdn.openbsd.org/pub/OpenBSD' > /etc/installurl

# Verify it is read correctly
pkg_add -v curl 2>&1 | head -3

# For a one-off session override
export PKG_PATH=https://ftp.eu.openbsd.org/pub/OpenBSD/$(uname -r)/packages/$(uname -m)/

pkg_add: Installing and Upgrading Packages

pkg_add is deceptively simple at the surface. Underneath it handles dependency resolution, package signing verification via signify(1), and conflict detection. The most important flags you need to know are -i (interactive, lets you choose between versions), -v (verbose, shows what it is doing), -u (update an installed package), and -z (fuzzy match, useful when you do not know the exact flavor name).

OpenBSD packages use a flavor system. Many packages have multiple build variants - for example, curl exists with and without GSSAPI support, and vim ships as vim, vim-no_x11, and vim-gtk3. When you install without specifying a flavor and multiple exist, pkg_add enters interactive mode and asks you to choose. Use -z to let it pick the best match automatically, or specify the flavor explicitly to avoid prompts in scripts.

Package names include version numbers in the repository but you reference them by stem when installing. pkg_add nginx installs the current version for your release. You cannot install an older version than what the repository carries for your OpenBSD release - that package simply does not exist in the tree.

For bulk installs in provisioning scripts, pass multiple package names in a single invocation. This is faster than calling pkg_add once per package because it resolves the full dependency graph once and downloads in parallel where possible.

# Install a single package
pkg_add nginx

# Install multiple packages at once
pkg_add git curl wget tmux vim--no_x11

# Install with verbose output to see what is happening
pkg_add -v postgresql-server

# Interactive mode to choose among flavors
pkg_add -i python3

# Fuzzy match - useful when you forget exact flavor suffix
pkg_add -z vim

# Update a specific installed package
pkg_add -u nginx

# Update all installed packages
pkg_add -u

# Simulate install without making changes
pkg_add -n git

Flavors and Stems: Understanding OpenBSD Package Naming

The OpenBSD package naming convention encodes meaningful information. The format is name-version-flavor, where flavor is optional and prefixed with two hyphens in the pkg_add command but one hyphen in the actual filename. This trips up almost everyone the first time.

When you run pkg_add vim--no_x11, you are specifying the no_x11 flavor. In the repository, the file is named something like vim-9.1.0-no_x11.tgz. The double hyphen is the pkg_add syntax for separating the stem from the flavor - not a typo.

Some packages like py3-pip are Python bindings and follow a different naming convention. The py3- prefix is part of the stem, not a flavor. Similarly, p5- packages are Perl modules.

If you are building internal tools and need a consistent naming scheme for your own packages - something that matters when you start using pkg_create to roll your own - think through your stem naming before you start. The same discipline applies to any project naming exercise, whether packages, DNS records, or services. Once names are in production configs and cron jobs, they are hard to change.

# List all available vim flavors in the repository index
pkg_info -Q vim

# Show what flavors are available for a package
pkg_add -v -n vim 2>&1

# Correct syntax: double hyphen before flavor
pkg_add vim--no_x11

# Python packages use py3- prefix
pkg_add py3-requests py3-flask

# Check what flavor of an installed package you have
pkg_info vim
// advertisement

pkg_info: Querying the Package Database

pkg_info is the read-only inspection tool for both installed packages and the remote repository. It reads from /var/db/pkg/ for local data. Every installed package has a directory there containing its packing list, install scripts, and metadata.

The most useful invocations for daily sysadmin work: pkg_info with no arguments lists every installed package. pkg_info -Q string searches the remote repository by name. pkg_info -L packagename lists every file that package installed. pkg_info -R packagename shows reverse dependencies - what other installed packages depend on this one.

pkg_info -A lists all installed packages with their full version strings. Pipe this to a file before a major system upgrade - it gives you a manifest to rebuild from if something goes wrong. We do this on our test servers before every OpenBSD version bump.

The -f flag shows the packing list (PLIST) for an installed package. The -M flag shows the package's MESSAGE file, which often contains post-install instructions that pkg_add prints once and then you never see again. If you installed a package and forgot what it told you to do after installation, pkg_info -M is how you recover that information.

# List all installed packages
pkg_info

# Search remote repository
pkg_info -Q nginx

# Show details on an installed package
pkg_info nginx

# List all files installed by a package
pkg_info -L nginx

# Show reverse dependencies
pkg_info -R libiconv

# Show post-install message
pkg_info -M postgresql-server

# Export full installed package list
pkg_info -A > /root/packages-$(date +%Y%m%d).txt

# Show dependencies of a package
pkg_info -d curl

# Check if a specific package is installed
pkg_info -e nginx && echo installed || echo not installed

pkg_delete: Removing Packages Cleanly

pkg_delete removes a package and, by default, leaves orphaned dependencies behind. Use -a to also remove automatically installed packages that are no longer needed by anything. This is analogous to apt autoremove but you have to ask for it explicitly.

The -f flag forces removal even when other packages depend on the target. Use this carefully - it can break installed software. The correct approach is to first check pkg_info -R to see what depends on a package before deleting it.

Orphaned packages accumulate on servers that have seen many install-and-remove cycles. Run pkg_delete -a periodically to clean them up, but review the list first with the -n dry-run flag.

# Remove a package
pkg_delete nginx

# Remove package and unused dependencies
pkg_delete -a nginx

# Dry run to see what would be removed
pkg_delete -n -a nginx

# Force remove even if other packages depend on it
# Use only when you know what you are doing
pkg_delete -f libssl

# Remove all orphaned packages system-wide
pkg_delete -a

# Check what depends on a package before deleting
pkg_info -R libiconv

pkg_check: Auditing Package Integrity

pkg_check validates that installed packages are intact - it checks that all files listed in each package's packing list still exist and that their checksums match. Run it after an interrupted installation, after a system crash, or after you suspect manual file edits corrupted something.

pkg_check with no arguments checks every installed package. It is thorough and slow on systems with many packages. Run it during maintenance windows.

The -B flag rebuilds the binary package database at /var/db/pkg/.byname/, which is a fast lookup index. Corrupt .byname entries cause confusing errors in pkg_add and pkg_info. If you see unexpected errors from the package tools, try pkg_check -B before anything else.

On our test server with 340 installed packages, a full pkg_check run takes about 45 seconds on a modern NVMe disk. On spinning rust with many small files, expect it to take several minutes.

# Check all installed packages for integrity
pkg_check

# Rebuild the binary package database index
pkg_check -B

# Verbose output to see each package as it is checked
pkg_check -v

# Check a specific package only
pkg_check nginx
// advertisement

Upgrading All Packages After a Version Bump

When you upgrade OpenBSD itself - say from 7.5 to 7.6 using sysupgrade(8) - your installed packages do not automatically update to the new release's versions. After sysupgrade completes and you reboot into the new kernel, you need to update /etc/installurl if it was version-pinned, then run pkg_add -u to pull in the 7.6 versions of everything.

The recommended sequence after a major version upgrade is: verify uname -r shows the new version, confirm /etc/installurl points to the correct mirror (cdn.openbsd.org without a version number handles this automatically), then run pkg_add -u with verbose output. Watch for packages that are no longer in the new release - they will produce errors and need to be handled manually.

Some packages change names between releases or are split into subpackages. The pkg_add -u run will flag these. You may need to pkg_delete the old name and pkg_add the new one.

For teams managing many OpenBSD systems, repeatable package provisioning matters. Writing a package list to a file and replaying it on fresh installs is standard practice. Some teams integrate this into their automation pipelines. If you are building DevOps workflows around OpenBSD provisioning, tools like those aggregated at taskbotshub.ai can help automate the repetitive parts of fleet management, including post-upgrade package reconciliation.

After pkg_add -u completes, run pkg_check to verify integrity, then pkg_delete -a to remove any orphans left by the upgrade.

# After sysupgrade, confirm you are on the new release
uname -r

# Confirm /etc/installurl is correct
cat /etc/installurl

# Update all packages to new release versions
pkg_add -u -v 2>&1 | tee /root/upgrade-$(date +%Y%m%d).log

# Remove orphaned packages after upgrade
pkg_delete -a

# Verify package integrity after upgrade
pkg_check

# Full recommended post-upgrade sequence
pkg_add -u && pkg_delete -a && pkg_check

Package Signing and Security Verification

OpenBSD packages are signed using signify(1), the same tool used to sign base system releases. Every package in the official repository carries a signature that pkg_add verifies automatically before installation. You cannot disable this without passing -D unsigned, which should never happen in production.

The public keys for each release live in /etc/signify/. The naming convention is openbsd-NN-pkg.pub where NN is the major version number without the dot. For OpenBSD 7.6, the key is openbsd-76-pkg.pub.

If you are building and distributing internal packages with pkg_create, you need to sign them yourself using signify -S. Generate a dedicated signing key pair for your organization's packages. Store the private key offline or in a secrets manager - not on the build server. The public key goes on every machine that will install your packages, into /etc/signify/.

Packages fetched from unofficial sources or third-party mirrors that do not carry valid signatures will cause pkg_add to abort. This is correct behavior. Do not work around it.

# View the signing keys on your system
ls /etc/signify/

# Verify a locally downloaded package manually
signify -V -p /etc/signify/openbsd-76-pkg.pub -m package.tgz

# Generate a signing key pair for internal packages
signify -G -p /path/to/myorg-pkg.pub -s /path/to/myorg-pkg.sec

# Sign an internal package you built
signify -S -s /path/to/myorg-pkg.sec -m myapp-1.0.tgz

# Install a signed internal package from a local path
pkg_add /path/to/myapp-1.0.tgz

Building and Installing Local Packages with pkg_create

pkg_create builds a .tgz package from a staging directory and a packing list. You use this when you need to distribute software that is not in the official ports tree, or when you need to roll out internally developed tools across a fleet of OpenBSD machines.

A minimal package requires three files: CONTENTS (the packing list), DESC (a description), and COMMENT (a one-line summary). These live in a metadata directory that you pass to pkg_create with the -d flag. The actual files to package live in a separate staging directory.

The CONTENTS file uses a line-per-entry format. @name sets the package name. @version sets the version. Lines beginning with @ are directives; plain lines are file paths relative to the install prefix. Dependencies are declared with @depend.

For packages that need to run commands on install or deinstall, create +INSTALL and +DEINSTALL scripts in the metadata directory. These are shell scripts that receive the package name and either 'PRE-INSTALL', 'POST-INSTALL', or 'DEINSTALL' as arguments.

If your internal tool is a long-running service, the +INSTALL script should create the rcctl(8) entry and set up the rc.d script. Distribute the rc.d script as part of the package via CONTENTS.

# Minimal package structure
mkdir -p /tmp/mypkg/{meta,stage/usr/local/bin}
cp myapp /tmp/mypkg/stage/usr/local/bin/

# Create packing list
cat > /tmp/mypkg/meta/CONTENTS << 'EOF'
@name myapp
@version 1.0
@comment My internal application
@owner root
@group bin
@mode 755
bin/myapp
EOF

# Create description files
echo 'Internal application for widget processing' > /tmp/mypkg/meta/DESC
echo 'myapp - widget processor' > /tmp/mypkg/meta/COMMENT

# Build the package
pkg_create -d /tmp/mypkg/meta -f /tmp/mypkg/meta/CONTENTS \
  -p /usr/local -B /tmp/mypkg/stage \
  /tmp/myapp-1.0.tgz

# Install it
pkg_add /tmp/myapp-1.0.tgz
// advertisement

Scripting Package Management Without Interaction

Automated provisioning breaks on interactive prompts. When running pkg_add in scripts - cloud-init, Ansible, or shell provisioners - you need to suppress all interactivity. Three flags handle this: -I disables interactive mode, -z enables fuzzy matching to avoid ambiguous package prompts, and -x suppresses progress output.

For packages where multiple flavors exist and -z does not uniquely resolve one, specify the full flavor name explicitly in your package list. Maintain a canonical package list file per server role. This also serves as documentation.

When pkg_add exits non-zero, check the exit code. Exit code 1 means at least one package failed. In provisioning scripts, run with set -e or check explicitly. A partial install is worse than a failed install because it is harder to detect.

For Ansible users, the openbsd_pkg module wraps pkg_add and handles idempotency. If you are writing raw shell provisioners rather than using Ansible, test your package list against a freshly installed OpenBSD VM before production deployment. The behavior of pkg_add -u on an already-current system (it exits 0 and does nothing) differs from behavior on a system missing the package (it installs it), which is the idempotent behavior you want.

# Non-interactive install for scripts
pkg_add -I -z nginx postgresql-server git curl

# Install from a package list file
pkg_add -I -z $(cat /etc/pkg-list.txt | tr '\n' ' ')

# Example package list file format
cat > /etc/pkg-list.txt << 'EOF'
nginx
postgresql-server
redis
git
curl
wget
tmux
vim--no_x11
python3
py3-pip
EOF

# Ansible task example
# - name: Install packages
#   openbsd_pkg:
#     name: "{{ item }}"
#     state: present
#   loop: "{{ packages }}"

# Check exit code explicitly in scripts
if ! pkg_add -I -z nginx; then
  echo 'Package installation failed' >&2
  exit 1
fi

Using a Local Package Cache and Private Mirror

On networks with multiple OpenBSD servers, downloading every package from a public CDN for each machine wastes bandwidth and time. Set up a local mirror using a simple HTTP server serving a copy of the official package tree, or use a caching proxy.

The simplest approach is a caching HTTP proxy like Squid or nginx with proxy_cache. Point all machines at the proxy via PKG_PATH or /etc/installurl. Packages are fetched once and served from cache on subsequent requests. The packages are gzip-compressed tarballs with stable URLs by filename and release version, so cache hit rates are high.

A full local mirror requires about 25GB for a single architecture's packages. Use rsync to sync from an official mirror. The official mirror list is at https://www.openbsd.org/ftp.html. Use a cron job to sync nightly, but only update when a new release lands - packages within a release do not change after publication.

For air-gapped environments, download the full package tree for your release and architecture to removable media or an internal server. The structure is simple: $MIRROR/pub/OpenBSD/$RELEASE/packages/$ARCH/. All packages are in that flat directory along with index files that pkg_add reads.

# Rsync a local mirror for amd64
rsync -avz --delete \
  rsync://ftp.eu.openbsd.org/pub/OpenBSD/7.6/packages/amd64/ \
  /srv/openbsd-mirror/7.6/packages/amd64/

# Serve it with nginx
# Add to nginx.conf:
# server {
#   listen 80;
#   server_name mirror.internal;
#   root /srv/openbsd-mirror;
#   autoindex on;
# }

# Point clients at local mirror
echo 'http://mirror.internal/pub/OpenBSD' > /etc/installurl

# Nginx caching proxy config snippet
# proxy_cache_path /var/cache/nginx levels=1:2
#   keys_zone=pkgcache:10m max_size=30g
#   inactive=90d use_temp_path=off;

# Sync cron job
echo '0 3 * * 0 root rsync -az --delete rsync://cdn.openbsd.org/pub/OpenBSD/7.6/packages/amd64/ /srv/openbsd-mirror/7.6/packages/amd64/' >> /etc/crontab

The /var/db/pkg Directory: What Lives There

Every installed package owns a subdirectory under /var/db/pkg/ named after the package in full name-version format. For example, nginx-1.26.1 would create /var/db/pkg/nginx-1.26.1/. Inside are several files you should know.

+CONTENTS is the packing list showing every file the package owns, with checksums. This is what pkg_check reads when validating integrity. +DESC is the long description. +COMMENT is the one-liner. +REQUIRED_BY lists other installed packages that depend on this one - this is the forward link. +DEPENDS lists this package's dependencies.

If +REQUIRED_BY is empty or absent, the package has no reverse dependencies and can be safely removed as an orphan. This is what pkg_delete -a uses to identify cleanup candidates.

Do not manually edit anything in /var/db/pkg/. If you need to fix a corrupt database, use pkg_check -B to rebuild the index. For serious corruption, reinstall the affected packages.

The .byname/ directory inside /var/db/pkg/ is the fast lookup index. It contains symlinks from short names to the full versioned directories. This is how pkg_info -e nginx works without you specifying the version.

# List all installed package directories
ls /var/db/pkg/

# Inspect a specific package's database entry
ls /var/db/pkg/nginx-1.26.1/

# View the packing list
cat /var/db/pkg/nginx-1.26.1/+CONTENTS

# See what depends on a package (raw file)
cat /var/db/pkg/nginx-1.26.1/+REQUIRED_BY 2>/dev/null || echo 'No dependents'

# The fast lookup index
ls /var/db/pkg/.byname/ | head -10

# Rebuild index if corrupted
pkg_check -B
// advertisement