Multiple outputs
Multiple outputs
Goal: Use multi-output derivations (out, dev, man, doc, …) to keep runtime closures small while preserving headers and debug assets for dependents.
Why it matters
A single $out that ships headers, man pages, static libs, and a 2 MB binary forces every dependent to download and GC the world. Multi-output packaging is how nixpkgs stays usable on laptops and CI runners.
For fleet images, multi-out is a disk and bandwidth control: servers need out, build machines need dev, humans sometimes need man.
Theory 1 — Outputs are separate store paths
/nix/store/hash-foo-1.0 ← out (runtime)
/nix/store/hash-foo-1.0-dev ← dev (headers, pkg-config)
/nix/store/hash-foo-1.0-man ← man
/nix/store/hash-foo-1.0-doc ← html docs
Each is a different path. References between them are tracked so GC stays correct: runtime out should not reference dev if you did it right.
stdenv.mkDerivation {
pname = "foo";
version = "1.0";
outputs = [ "out" "dev" "man" ];
# …
}Default if omitted: outputs = [ "out" ];.
First output is special
The first element of outputs is the default when someone depends on the package without selecting an output. Convention: out first.
outputs = [ "out" "dev" "man" ]; # good
outputs = [ "dev" "out" ]; # surprising defaultsTheory 2 — Conventional output roles
| Output | Typical content | Who needs it |
|---|---|---|
out |
Binaries, shared libs, runtime data | Everyone running the tool |
dev |
Headers, .pc, static libs, cmake files |
Compiling dependents |
lib |
Sometimes shared libs split from bins | Rare patterns / legacy |
man |
Man pages | Humans; optional on servers |
doc |
HTML/PDF docs | Optional |
info |
Texinfo | Optional |
devdoc |
Heavy API docs | Dev machines |
debug |
Separate debuginfo (with separateDebugInfo) |
Debuggers |
Environment variables in the build
| Var | Points at |
|---|---|
$out |
First output (usually runtime) |
$dev |
Dev output path |
$man |
Man output |
$outputs |
List of outputs |
Install into the correct root:
install -Dm755 foo $out/bin/foo
install -Dm644 foo.h $dev/include/foo.h
install -Dm644 foo.pc $dev/lib/pkgconfig/foo.pc
install -Dm644 foo.1 $man/share/man/man1/foo.1Theory 3 — Why dependents get smaller
app depends on libfoo
→ needs libfoo.out (and its runtime closure)
→ does NOT need libfoo.dev unless building against headers
nix path-info -r on an application should not pull *-dev for every library if packaging is correct.
nix path-info -r ./result | rg -- '-dev$' || echo "no dev outputs in closure (good for runtime)"Reference leaks
If a file in $out contains the store path of $dev (e.g. a script hardcoding include paths), Nix records a reference, and GC / closure pull dev into runtime. That defeats multi-out.
# After build, hunt for leaks (illustrative)
nix path-info -r .#libgreet | rg dev || true
strings result/bin/* | rg 'dev' || trueTheory 4 — moveToOutput and helpers
stdenv provides helpers used heavily in nixpkgs:
moveToOutput share/man "$man"
moveToOutput include "$dev"
moveToOutput lib/pkgconfig "$dev"| Hazard | Result | Mitigation |
|---|---|---|
Headers installed to $out |
Runtime closure grows | Move to $dev |
out references $dev |
Forces dev into runtime closure |
Fix install paths / moveToOutput |
Forgetting outputs but installing to $dev |
Empty/wrong paths | Declare outputs first |
| pkg-config file wrong prefix | Consumers cannot find libs | Check .pc prefix/libdir |
Static libs in out |
Heavier runtime | Prefer static in dev |
Theory 5 — Consuming multi-output packages
nativeBuildInputs = [ pkg-config ];
buildInputs = [ foo ]; # pulls foo.dev for compilation via setup hooks usuallySetup hooks from dev outputs typically put headers on the search path when foo is in buildInputs.
Selecting outputs explicitly:
foo.dev # the dev store path attribute
foo.out
foo.mannix build nixpkgs#openssl
nix build nixpkgs#openssl.dev -o o-dev
nix build nixpkgs#openssl.man -o o-man
du -sh result o-dev o-manMultiple outputs and nix shell
Depending on tooling, you may need the package default output only—headers come when building other packages, not always when “shelling” into a library.
Theory 6 — separateDebugInfo
stdenv.mkDerivation {
# …
separateDebugInfo = true;
}When supported, debug symbols go to a separate output so runtime stays lean while GDB can still find debuginfo via standard mechanisms.
| Goal | Approach |
|---|---|
| Small production closure | strip + multi-out; optional separate debug |
| Debug a crash on a server | install debug outputs temporarily; don’t ship forever |
| Reproducible debug paths | Prefer separateDebugInfo over ad-hoc unstripped bins |
Theory 7 — When single-output is fine
| Package kind | Multi-out? |
|---|---|
| Tiny shell script CLI | Usually no |
| Go/Rust single static-ish binary | Often single out |
| Shared C library used by many pkgs | Yes (out+dev) |
| Huge docs package | Split doc/man |
| Firmware blobs | Case-by-case |
Do not multi-out for fashion—multi-out when dependents would otherwise drag weight.
Worked example — toy multi-output C library + app
/* src/libgreet/greet.c */
#include "greet.h"
const char *greet_msg(void) { return "hello multi-out"; }/* src/libgreet/greet.h */
#pragma once
const char *greet_msg(void);/* src/greetapp/main.c */
#include <stdio.h>
#include "greet.h"
int main(void) { puts(greet_msg()); return 0; }# pkgs/libgreet.nix
{ stdenv, lib }:
stdenv.mkDerivation {
pname = "libgreet";
version = "0.1.0";
outputs = [ "out" "dev" "man" ];
src = ../src/libgreet;
buildPhase = ''
runHook preBuild
$CC -shared -fPIC greet.c -o libgreet.so
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 libgreet.so $out/lib/libgreet.so
install -Dm644 greet.h $dev/include/greet.h
mkdir -p $dev/lib/pkgconfig
cat > $dev/lib/pkgconfig/libgreet.pc <<EOF
prefix=$out
libdir=$out/lib
includedir=$dev/include
Name: libgreet
Version: 0.1.0
Libs: -L\''${libdir} -lgreet
Cflags: -I\''${includedir}
EOF
mkdir -p $man/share/man/man3
echo ".TH GREET 3" > $man/share/man/man3/greet.3
runHook postInstall
'';
meta = {
description = "Tiny greet library (multi-output lab)";
license = lib.licenses.mit;
};
}# pkgs/greetapp.nix
{ stdenv, lib, libgreet, pkg-config }:
stdenv.mkDerivation {
pname = "greetapp";
version = "0.1.0";
src = ../src/greetapp;
nativeBuildInputs = [ pkg-config ];
buildInputs = [ libgreet ];
buildPhase = ''
runHook preBuild
$CC main.c -o greetapp $(pkg-config --cflags --libs libgreet)
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 greetapp $out/bin/greetapp
runHook postInstall
'';
meta = {
mainProgram = "greetapp";
license = lib.licenses.mit;
};
}# flake fragment
{
packages = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
libgreet = pkgs.callPackage ./pkgs/libgreet.nix { };
in {
libgreet = libgreet;
greetapp = pkgs.callPackage ./pkgs/greetapp.nix { inherit libgreet; };
default = self.packages.${system}.greetapp;
});
}nix build .#libgreet
nix build .#libgreet.dev -o result-dev
nix build .#libgreet.man -o result-man
nix build .#greetapp
./result/bin/greetapp
nix path-info -r ./result | rg libgreetLab — multi-step
Suggested workspace: ~/lab/nixos/packaging/multi-out
Step 1 — Inspect an existing multi-out package
nix eval nixpkgs#openssl.outputs
nix build nixpkgs#openssl -o o-out
nix build nixpkgs#openssl.dev -o o-dev
du -sh o-out o-devRecord sizes.
Step 2 — Build libgreet with three outputs
Implement the toy library (or split your stdenv tool into lib + bin).
Step 3 — Verify separate paths
nix build .#libgreet
nix build .#libgreet.dev
nix build .#libgreet.man
nix path-info .#libgreet .#libgreet.dev .#libgreet.manStep 4 — Build greetapp against libgreet
Confirm the app runs and its runtime closure includes libgreet out but ideally not manpages.
nix path-info -r ./result | rg 'libgreet'Step 5 — Deliberate mistake
Install a header into $out/include and rebuild app. Compare closure size / path-info. Fix it.
Step 6 — Document policy
In NOTES.md: when you will use outputs = [ "out" "dev" ] for internal packages vs single-output scripts.
Step 7 — Optional separateDebugInfo
separateDebugInfo = true;Build and note the extra debug output if supported on your platform.
Step 8 — pkg-config break/fix
Corrupt the .pc libdir; observe consumer failure; restore.
Exercises
- Compare
du -shforopenssl.outvsopenssl.devon your system.
- Find three multi-out packages in nixpkgs used by your hosts; list their
outputs.
- Explain in two sentences how GC roots interact with multi-out (each path separately).
- Design outputs for a hypothetical SDK: runtime daemon, headers, CLI, docs.
- Measure greetapp closure before/after the intentional header-in-out mistake.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
dev empty |
Not installing into $dev; check outputs list |
Runtime needs dev |
Cross-reference leak; fix files that embed $dev paths |
pkg-config cannot find package |
Ensure .pc in dev and package in buildInputs |
Only result symlink |
Multi-out: use nix build .#pkg.dev or -o names |
| Broke library for dependents | Test a dependent package in the same flake |
Docs in out |
moveToOutput or install directly to $doc/$man |
| Wrong outputs order | Put out first |
Static lib only in out |
Move .a to dev when possible |
Checkpoint
- Can explain multi-out as multiple store paths
- Toy (or real) package produces
out+dev
- Dependent app builds against
dev, runs againstout
- Closure inspection done with
nix path-info
- One deliberate mis-install fixed
- Policy note written for internal packages
- Understand reference leaks
Commit
git add .
git commit -m "packaging: multiple outputs libgreet + greetapp"Write three personal gotchas before debugging builds.