Using DESTDIR in a Makefile

Overview

When installing software built from source, a common and useful pattern is to support a staged install using DESTDIR.

This allows a project to install files into a temporary directory tree first, instead of writing directly into system locations like /usr/local or /etc.

The basic idea is:

define the intended install layout with variables like PREFIX, BINDIR, LIBDIR, etc.

prepend DESTDIR during installation

let the caller choose whether installation goes to the real root filesystem or a temporary staging root

This makes installs safer, more flexible, and more packaging-friendly.

Why Use DESTDIR

  1. Avoid running install logic as root

A normal source build often looks like this:

./configure make sudo make install

That only works cleanly if make install does nothing except copy already-built files.

In practice, some projects mistakenly do build work during install. If that happens, running sudo make install can break because:

root may not have the same PATH

language toolchains may not be available

caches and config may resolve under /root

files in the source tree may become root-owned

Supporting DESTDIR lets you run install logic as a normal user first, then elevate only for the final copy into the real filesystem.

  1. Support package building

Most packaging systems do not install files directly into /usr, /etc, and so on while assembling a package.

Instead, they stage files into a temporary root tree such as:

pkgroot/ usr/local/bin/myapp usr/local/share/myapp/... etc/myapp/config.conf

That staged directory is then turned into a package or copied into a target system.

DESTDIR is the standard convention that makes this possible.

  1. Make installs safer to inspect

A staged install can be reviewed before touching the real system.

Example:

make install DESTDIR="$PWD/pkgroot" find "$PWD/pkgroot"

This lets you verify:

what files are being installed

where they will go

whether paths are correct

whether anything unexpected is being written

That beats discovering after the fact that your install target quietly scribbled into /etc like an overconfident raccoon.

  1. Separate logical install paths from temporary staging paths

DESTDIR and PREFIX solve different problems:

PREFIX controls the intended install location

DESTDIR adds a temporary root for staging

Example:

PREFIX = /usr/local BINDIR = $(PREFIX)/bin DESTDIR = /tmp/pkgroot

Final staged file path becomes:

/tmp/pkgroot/usr/local/bin/myapp

Later, that file may be copied into:

/usr/local/bin/myapp

This separation is what makes packaging and system installs behave sanely for once.

DESTDIR Is a Convention, Not a Built-In Make Feature

make itself does not know anything about DESTDIR.

This works:

make install DESTDIR=/tmp/pkgroot

only because the Makefile author chose to write install rules using $(DESTDIR).

So supporting DESTDIR means explicitly designing install rules to prepend it.

The Basic Pattern

A Makefile that supports staged installs typically looks like this:

PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin LIBDIR ?= $(PREFIX)/lib DATADIR ?= $(PREFIX)/share/myapp

install: install -d $(DESTDIR)$(BINDIR) install -d $(DESTDIR)$(LIBDIR) install -d $(DESTDIR)$(DATADIR)

install -m 0755 myapp $(DESTDIR)$(BINDIR)/myapp
install -m 0644 libmystuff.so $(DESTDIR)$(LIBDIR)/libmystuff.so
cp -R assets/. $(DESTDIR)$(DATADIR)/

How this behaves

With no DESTDIR:

make install

files go to:

/usr/local/bin/myapp /usr/local/lib/libmystuff.so /usr/local/share/myapp/...

With DESTDIR:

make install DESTDIR="$PWD/pkgroot"

files go to:

$PWD/pkgroot/usr/local/bin/myapp $PWD/pkgroot/usr/local/lib/libmystuff.so $PWD/pkgroot/usr/local/share/myapp/... Recommended Makefile Structure

A clean Makefile should separate:

build steps

install steps

Example:

PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin

TARGET := myapp

.PHONY: all build install clean

all: build

build: $(TARGET)

$(TARGET): main.o $(CC) -o $@ main.o

main.o: main.c $(CC) -c main.c

install: $(TARGET) install -d $(DESTDIR)$(BINDIR) install -m 0755 $(TARGET) $(DESTDIR)$(BINDIR)/$(TARGET)

clean: rm -f $(TARGET) main.o

This is good because:

build creates the artifact

install copies the artifact

DESTDIR affects only install location

there is no need for root during compilation

Better Pattern for Multi-Step Projects

If your project has binaries, libraries, configs, and docs, use dedicated path variables.

Example:

PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin LIBDIR ?= $(PREFIX)/lib ETCDIR ?= /etc/myapp DOCDIR ?= $(PREFIX)/share/doc/myapp

install: install -d $(DESTDIR)$(BINDIR) install -d $(DESTDIR)$(LIBDIR) install -d $(DESTDIR)$(ETCDIR) install -d $(DESTDIR)$(DOCDIR)

install -m 0755 myapp $(DESTDIR)$(BINDIR)/myapp
install -m 0644 libmyapp.so $(DESTDIR)$(LIBDIR)/libmyapp.so
install -m 0644 myapp.conf $(DESTDIR)$(ETCDIR)/myapp.conf
install -m 0644 README.md $(DESTDIR)$(DOCDIR)/README.md

This makes the install layout explicit and predictable.

Important Rules for DESTDIR

  1. Only use DESTDIR in install paths

Do this:

install -m 0755 myapp $(DESTDIR)$(BINDIR)/myapp

Do not do this in build rules:

$(CC) -o $(DESTDIR)$(BINDIR)/myapp main.o

DESTDIR is for installation, not compilation.

  1. Keep PREFIX and DESTDIR separate

Do this:

BINDIR ?= $(PREFIX)/bin install -m 0755 myapp $(DESTDIR)$(BINDIR)/myapp

Do not hardcode paths like this:

install -m 0755 myapp /usr/local/bin/myapp

Hardcoded paths make staging impossible and packaging miserable.

  1. Create directories explicitly

Use install -d or mkdir -p before copying files:

install -d $(DESTDIR)$(BINDIR)

This ensures the staged tree is built correctly and repeatably.

  1. Install built artifacts, do not rebuild them

This is one of the most important habits.

Bad:

install: cargo build --release install -m 0755 target/release/myapp $(DESTDIR)$(BINDIR)/myapp

Better:

build: cargo build --release

install: install -d $(DESTDIR)$(BINDIR) install -m 0755 target/release/myapp $(DESTDIR)$(BINDIR)/myapp

Best:

install: build install -d $(DESTDIR)$(BINDIR) install -m 0755 target/release/myapp $(DESTDIR)$(BINDIR)/myapp

This keeps toolchains out of privileged install flows and avoids weird environment issues.

Example: Rust Project with DESTDIR PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin TARGET ?= target/release/myapp

.PHONY: build install clean

build: cargo build --release

install: build install -d $(DESTDIR)$(BINDIR) install -m 0755 $(TARGET) $(DESTDIR)$(BINDIR)/myapp

clean: cargo clean

Usage:

make build make install DESTDIR="$PWD/pkgroot" sudo rsync -a --dry-run "$PWD/pkgroot"/ / sudo rsync -a "$PWD/pkgroot"/ / Example: C Project with DESTDIR PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin

CC ?= cc CFLAGS ?= -O2

TARGET := hello

.PHONY: all install clean

all: $(TARGET)

$(TARGET): hello.o $(CC) $(CFLAGS) -o $@ hello.o

hello.o: hello.c $(CC) $(CFLAGS) -c hello.c

install: $(TARGET) install -d $(DESTDIR)$(BINDIR) install -m 0755 $(TARGET) $(DESTDIR)$(BINDIR)/$(TARGET)

clean: rm -f $(TARGET) hello.o Testing DESTDIR Support

A quick way to verify your Makefile supports staging properly:

make -n install DESTDIR="$PWD/pkgroot"

Inspect the output.

Good output will show paths like:

/tmp/project/pkgroot/usr/local/bin/myapp

Bad output will still show absolute paths like:

/usr/local/bin/myapp

If absolute paths remain, then your Makefile is not honoring DESTDIR.

Common Mistakes Rebuilding during install

If install triggers compilers or language toolchains, you may run into root environment problems.

Hardcoding /usr/local

This blocks staging and makes packaging harder.

Mixing DESTDIR into logical path variables

Keep logical install paths in PREFIX, BINDIR, etc. Apply DESTDIR only at install time.

Forgetting config paths outside PREFIX

Some files belong under /etc, not under /usr/local. That is fine. Just still prepend DESTDIR during installation:

ETCDIR ?= /etc/myapp install -m 0644 myapp.conf $(DESTDIR)$(ETCDIR)/myapp.conf Recommended Workflow

For local system installs:

make make install DESTDIR="$PWD/pkgroot" sudo rsync -a --dry-run "$PWD/pkgroot"/ / sudo rsync -a "$PWD/pkgroot"/ /

For package builds:

make make install DESTDIR="$PKGDIR"

For direct installs where install truly only copies files:

make sudo make install

That last one is fine when the install target is clean. The problem is that many projects are not nearly as clean as their authors imagine.

Summary

A Makefile should support DESTDIR because it allows:

staged installs

safer review of installed files

package building

avoiding root during build and install logic

cleaner separation between build and deployment

The core design rule is:

Build artifacts first. Install them second. Prepend DESTDIR during installation.

That pattern is simple, durable, and worth internalizing.

If you want this as a real README.md file, I can format it into a tighter version with a table of contents and shorter examples.