Building .deb and .rpm Packages
Shipping software as a tarball with an install script works and leaves the system with files nothing tracks. A package records what it owns, which means it can be removed cleanly, upgraded in place, verified, and checked for conflicts.
The quick route: fpm
Before the proper tooling, know that this exists, because for internal distribution it is frequently all you need.
gem install fpm
Build your software into a staging directory that mirrors the target filesystem:
staging/
usr/
bin/myapp
share/myapp/templates/
etc/
myapp/config.yml
lib/systemd/system/myapp.service
Then:
fpm -s dir -t deb \
-n myapp -v 1.2.0 \
--description "My application" \
--depends "libssl3" \
--after-install postinstall.sh \
-C staging .
fpm -s dir -t rpm -n myapp -v 1.2.0 -C staging .
Same directory, both formats. The resulting packages install, uninstall, and upgrade correctly.
They will not pass Debian or Fedora review, because they lack the metadata, licensing declarations, and build reproducibility those processes require. For distributing to your own servers, that does not matter.
Building a .deb properly
sudo apt install build-essential devscripts debhelper dh-make
The debian/ directory holds everything.
debian/control
Source: myapp
Section: utils
Priority: optional
Maintainer: Your Name <you@example.com>
Build-Depends: debhelper-compat (= 13), libssl-dev
Standards-Version: 4.6.2
Package: myapp
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: Short one-line description
A longer description, indented by one space.
.
A line containing only a dot is a paragraph break.
${shlibs:Depends} is the important part: debhelper inspects the compiled binaries and fills in the shared library dependencies automatically. Listing them by hand is both more work and less accurate.
The description format is strict. Continuation lines need exactly one leading space, and a lone dot makes a blank line.
debian/rules
An executable Makefile:
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_configure:
./configure --prefix=/usr --sysconfdir=/etc
dh $@ runs the whole debhelper sequence. Override individual steps only where the defaults are wrong. This modern form replaced a much longer file that spelled out every step.
Remember the tab requirement: this is a Makefile.
debian/changelog
myapp (1.2.0-1) unstable; urgency=medium
* New upstream release.
* Fix crash when config file is missing.
-- Your Name <you@example.com> Sun, 14 Sep 2026 10:00:00 +0000
The changelog determines the version. Not a variable somewhere: the top entry. Use dch to edit it, because the format is exacting and a malformed date silently breaks the build.
dch -i # new revision
dch -v 1.3.0-1
Building
dpkg-buildpackage -us -uc -b
lintian ../myapp_1.2.0-1_amd64.deb
lintian checks against Debian policy. It is pedantic, and most of what it reports is worth fixing.
Building an .rpm
sudo dnf install rpm-build rpmdevtools
rpmdev-setuptree
Everything lives in one spec file at ~/rpmbuild/SPECS/myapp.spec:
Name: myapp
Version: 1.2.0
Release: 1%{?dist}
Summary: Short one-line description
License: MIT
URL: https://example.com/myapp
Source0: %{name}-%{version}.tar.gz
BuildRequires: gcc, make, openssl-devel
Requires: openssl
%description
A longer description of the package.
%prep
%autosetup
%build
%configure
%make_build
%install
%make_install
%files
%license LICENSE
%doc README.md
%{_bindir}/myapp
%config(noreplace) %{_sysconfdir}/myapp/config.yml
%{_unitdir}/myapp.service
%changelog
* Sun Sep 14 2026 Your Name <you@example.com> - 1.2.0-1
- New upstream release
rpmbuild -ba ~/rpmbuild/SPECS/myapp.spec
rpmlint ~/rpmbuild/RPMS/x86_64/myapp-1.2.0-1.*.rpm
%config(noreplace) is the tag to get right. It means an upgrade will not overwrite a config file the administrator has modified, installing the new version as .rpmnew instead. Omitting it means your upgrade silently discards their configuration.
The Debian equivalent is handled by dh_installdeb and conffiles, and it is the default behaviour there.
%files must list every file the package installs. A file in the buildroot that is not listed causes a build error, which is a useful check rather than an annoyance.
Maintainer scripts
Both formats run scripts at install and removal.
Debian: debian/postinst, prerm, postrm, preinst.
RPM: %post, %preun, %postun, %pre sections.
#!/bin/sh
set -e
case "$1" in
configure)
systemctl daemon-reload
systemctl enable myapp.service
;;
esac
Two rules that matter.
They must be idempotent. They run on install and on every upgrade. A script that creates a user must not fail when the user exists.
They must handle failure. set -e and a script that exits non-zero leaves the package half-configured, which is an unpleasant state to recover from.
For systemd units, dh_installsystemd and the RPM %systemd_post macros do this correctly and are preferable to writing it yourself. Our systemd service guide covers the unit file itself.
Build environments
Build on the oldest distribution release you intend to support. glibc is forward-compatible and not backward-compatible, so a binary built against a newer glibc fails on older systems with a version mismatch error.
# Debian: clean chroot
sudo pbuilder create --distribution bookworm
sudo pbuilder build myapp_1.2.0-1.dsc
# Fedora and RHEL
mock -r fedora-42-x86_64 --rebuild myapp-1.2.0-1.src.rpm
Clean chroots also catch missing Build-Depends, which your development machine hides because the libraries are already installed.
Containers work equally well and are easier to run in CI.
Distributing
A repository, so users get updates through their normal package manager. reprepro or aptly for Debian, createrepo_c for RPM. Sign it with GPG; an unsigned repository is an invitation.
createrepo_c /var/www/repo/
gpg --detach-sign --armor /var/www/repo/repodata/repomd.xml
Open Build Service builds for many distributions and hosts the repositories, and is free for open source.
A GitHub release with the package files attached is the low-effort option: no automatic updates, and it works.
Our packages and repositories explainer covers what the client side is doing with all of this.
Frequently Asked Questions
Why build a package instead of shipping a tarball with an install script?
A package records every file it owns, so the system can uninstall it cleanly, detect conflicts with other packages, verify integrity, and upgrade it in place. An install script writes files nobody tracks, which is how systems accumulate untracked cruft that survives every attempt to remove it.
What is the quickest way to make a package from an existing build?
fpm, the Effing Package Management tool, builds a .deb or .rpm from a directory tree in one command without writing any spec or control files. It produces a package that installs and uninstalls correctly, which is enough for internal distribution even though it will not pass distribution review.
What is the difference between a spec file and a debian directory?
An RPM spec file is a single file containing metadata, build steps, and the file list. Debian uses a debian directory holding several files, with control for metadata, rules as a Makefile for the build, and changelog determining the version. The concepts match, the layout does not.
How do I handle dependencies in my package?
Declare them in the control file Depends field for Debian or the Requires tag for RPM. Both tools can also detect shared library dependencies automatically by inspecting the compiled binaries, which catches most of what matters and is more reliable than listing them by hand.
Do I need a build environment matching the target distribution?
Yes, for anything compiled. A binary built against a newer glibc will not run on an older system, so the build should happen on the oldest release you intend to support. Container images or chroot tools such as pbuilder and mock are the normal way to manage this.
What does the epoch field in a package version do?
It overrides normal version comparison. If you accidentally publish version 2.0 and then need to ship 1.9 as the newer package, an epoch of 1 makes 1:1.9 sort above 2.0. Once set an epoch can never be removed, so it is a repair mechanism rather than something to use casually.