Package Management
apt, dnf, brew, pacman, nix — and why Nix solved 'works on my machine'.
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:
- Use language-level dependency isolation (Python venv, Node
node_modules, Go modules). Solves it for everything within that language. - Use containers. Each app gets its own root filesystem with its own libfoo. The classic answer; what Docker exists for.
- 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 throughnix-shellorflake.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 holdfor 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 installfor 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":
which <binary>/command -v <binary>— is it on PATH?<manager> list <pkg>— does the manager think it's installed? (apt list --installed,brew list,dnf list installed, etc.)<manager> info <pkg>— what version? Where did it install to?ls -la $(which <binary>)— what is on disk, who owns it?- Two conflicting installs? Likely a system + brew conflict; check
/usr/binvs/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- cliaptfree tier
Debian/Ubuntu high-level package manager. `apt install`, `apt upgrade`.
- clidnffree tier
Fedora/RHEL package manager. Successor to yum.
- cliHomebrewfree tier
User-space macOS/Linux package manager. The macOS default for dev tools.
- clipacmanfree tier
Arch Linux package manager. `-Syu` updates the whole system.
- cliNixfree tier
Functional, reproducible package manager. `nix-shell`, flakes, `nix develop`.
- cliasdf / misefree tier
Per-project versions of any runtime (node, python, go) — across distros.