unix · level 9

Package Management

apt, dnf, brew, pacman, nix — and why Nix solved 'works on my machine'.

175 XP

Package Management

A package manager is two things: a tool that copies files into the right places, and a database that tracks which files came from where. Get those two things right and "install nginx" works the same way every time. Get them wrong and you have a Linux machine.

Analogy

A package manager is the building's superintendent. They keep a master list of every appliance in every flat, who installed it, what version it is, and what it depends on (the boiler needs the gas line; the gas line needs the meter). When a tenant asks "can you install a dishwasher?", the super doesn't just dump it in the kitchen — they check the existing wiring, refuse if there's a conflict, and update the master list. You don't keep your own log of what's in your flat — that's the super's job. Bypass them and the next person who lives there has no idea what you did.

The mental model

There are two distinct jobs, often confused:

  • System package manager — owns the OS itself. /usr/bin/python3, /etc/nginx, the kernel modules. One per distro family. You need root.
  • User-space package manager — installs into a subdirectory of $HOME (or /opt/homebrew). Lives alongside the system. No root needed.

Don't fight the OS. Let the system manager handle daemons, shared libraries, and anything that needs systemd. Use a user-space manager (or a per-language tool) for dev tools and language runtimes.

The system managers, by family

Debian / Ubuntu — apt

sudo apt update                    # refresh package metadata
sudo apt install -y nginx          # install
apt search nginx                   # search
sudo apt upgrade                   # upgrade everything
sudo apt autoremove                # remove orphan dependencies

Underneath, apt calls dpkg, the lower-level tool that actually unpacks .deb archives. You almost never call dpkg directly — apt is the right interface.

Fedora / RHEL / Rocky — dnf

sudo dnf install -y nginx
dnf search nginx
sudo dnf upgrade
sudo dnf autoremove

dnf replaced yum on Fedora 22+ and RHEL 8+. The CLI is intentionally similar; on those systems yum is now an alias to dnf. RPM (rpm -qa) is the lower layer.

Arch — pacman

sudo pacman -Syu nginx     # synchronise + update + install
sudo pacman -Ss nginx      # search
sudo pacman -R nginx       # remove

Arch is a rolling release — there's no Arch 22.04 or Arch 24.10. -Syu always upgrades everything to the current head. The trade-off is bleeding-edge software at the cost of "stable" being a moving target.

macOS — brew

brew install nginx
brew upgrade
brew search nginx
brew uninstall nginx

Homebrew installs to /opt/homebrew (Apple Silicon) or /usr/local (Intel). It's a user-space manager — it never overwrites Apple-managed files. There's no system manager on macOS in the apt sense; brew fills that niche by convention.

For GUI apps, brew install --cask:

brew install --cask iterm2

NixOS — nix

The outlier. Every package installs to /nix/store/<hash>-<name>-<version>/, and your shell PATH includes a stable symlink. So:

  • Two versions of the same library can coexist.
  • An install can be reproduced on any other Nix system from the same flake.lock.
  • A failed upgrade can be rolled back atomically.
nix-env -iA nixpkgs.nginx          # install (legacy)
nix-shell -p nginx                 # ephemeral shell with nginx
nix develop                        # flake-defined dev shell

If you've ever spent an afternoon debugging "it works on my machine but not in CI", Nix's reproducibility is the actual fix. The trade-off is a steeper learning curve and a smaller package set than apt/dnf.

User-space managers worth knowing

Tool What it manages
pip --user / pipx Python packages into ~/.local.
cargo install Rust binaries into ~/.cargo/bin.
npm install -g (with prefix) Node CLIs into a user-owned prefix.
go install Go binaries into ~/go/bin.
asdf / mise Per-project versions of Node/Python/Ruby/Go, etc.

The pattern: each language has its own ecosystem manager. Don't try to install Node packages with apt — the version is always months stale. Use the language's own tool, or asdf/mise for cross-language version pinning.

Versioned vs rolling release

Two distribution philosophies:

  • Versioned (Debian, Ubuntu LTS, RHEL): packages frozen at release; only security backports. Upgrading the OS = upgrading every package atomically. Stable, predictable, sometimes ancient.
  • Rolling (Arch, Gentoo, openSUSE Tumbleweed, macOS-with-brew): always-current. No "upgrade event" — packages move forward continuously.

Production servers want versioned, with long support windows. Developer laptops want rolling, with current toolchains. Both are correct for their context.

"Works on my machine" — and how Nix solves it

The classic problem: you install libfoo 1.4 for project A. Project B needs libfoo 1.2. Apt can have one of them, not both. You install libfoo 1.2; project A breaks. You install libfoo 1.4; project B breaks.

Solutions, in order of escalation:

  1. Use language-level dependency isolation (Python venv, Node node_modules, Go modules). Solves it for everything within that language.
  2. Use containers. Each app gets its own root filesystem with its own libfoo. The classic answer; what Docker exists for.
  3. Use Nix. Each install lives in /nix/store/<hash>-libfoo-1.2/ and /nix/store/<hash>-libfoo-1.4/ simultaneously. Your shell PATH selects which one is "current" — and a project can pin its own version through nix-shell or flake.nix.

Containers are the practical, ship-it answer for production. Nix is the elegant, "I want my dev machine to feel sane" answer for development.

Lockfiles — the version-pinning leak

Two of the package-manager families pin versions in lockfiles by default:

  • apt has apt-mark hold for keeping a specific version, but no built-in lockfile model — you typically pin via base images for reproducibility.
  • dnf is similar.
  • brew has no native lockfile; people use Brewfile.
  • nix has flake.lock — the strongest lockfile in any package manager, hashing every input.

For dev environments where reproducibility matters: use a flake (Nix), or commit a Brewfile / requirements.txt / package-lock.json / Cargo.lock per project. System managers are weaker here than language managers.

Worth knowing about

  • Snap (Ubuntu)snap install for sandboxed apps. Universal across distros, occasionally annoying.
  • Flatpak — same idea, more popular for desktop apps.
  • AppImage — a single executable that runs anywhere; no installer.
  • Conda / Mamba — scientific-Python ecosystems with their own manager.
  • pacaur / yay — Arch's user-repository wrappers (AUR).

You don't need to master all of them. You do need to recognise which one you're looking at when something breaks.

A diagnostic loop

When "the package isn't installing" or "the binary isn't found":

  1. which <binary> / command -v <binary> — is it on PATH?
  2. <manager> list <pkg> — does the manager think it's installed? (apt list --installed, brew list, dnf list installed, etc.)
  3. <manager> info <pkg> — what version? Where did it install to?
  4. ls -la $(which <binary>) — what is on disk, who owns it?
  5. Two conflicting installs? Likely a system + brew conflict; check /usr/bin vs /opt/homebrew/bin.

Most "broken install" bugs are PATH bugs in disguise. The package is fine; your shell is finding the wrong one. Lesson 01 (PATH) covers the actual fix.

Tools in the wild

6 tools
  • aptfree tier

    Debian/Ubuntu high-level package manager. `apt install`, `apt upgrade`.

    cli
  • dnffree tier

    Fedora/RHEL package manager. Successor to yum.

    cli
  • Homebrewfree tier

    User-space macOS/Linux package manager. The macOS default for dev tools.

    cli
  • pacmanfree tier

    Arch Linux package manager. `-Syu` updates the whole system.

    cli
  • Nixfree tier

    Functional, reproducible package manager. `nix-shell`, flakes, `nix develop`.

    cli
  • asdf / misefree tier

    Per-project versions of any runtime (node, python, go) — across distros.

    cli