Creating .rpm packages for the Red Hat family

Mascote do LinuxPro colocando o software dentro de uma caixa marcada com o chapéu vermelho, na esteira de empacotamento

In the first part of this pair we built .deb packages for Debian and Ubuntu. Now it's the Red Hat family's turn — RHEL, Fedora, Rocky, AlmaLinux, and by extension openSUSE: let's create .rpm packages from scratch, from the spec file to your own dnf repository, covering rpmbuild, rpmlint, mock and GPG signing.

How RPM works

Unlike .deb (a directory you assemble by hand), the RPM world revolves around a single recipe file: the spec file. It describes metadata, dependencies, how to compile, what to install and the changelog — and rpmbuild does the rest. To inspect any package:

rpm -qip htop-*.rpm      # metadados
rpm -qlp htop-*.rpm      # lista de arquivos
rpm -q --scripts htop    # scripts de instalação de um pacote instalado

Preparing the workbench

# Fedora / RHEL / Rocky / Alma
sudo dnf install rpm-build rpmdevtools rpmlint

# cria a árvore de trabalho em ~/rpmbuild
rpmdev-setuptree
tree ~/rpmbuild
# BUILD/ BUILDROOT/ RPMS/ SOURCES/ SPECS/ SRPMS/

Golden rule: never run rpmbuild as root — a %install that's poorly written can write to the actual system.

The spec file, section by section

Generate a skeleton and let's fill it in (meuapp as our example again):

cd ~/rpmbuild/SPECS
rpmdev-newspec meuapp
Name:           meuapp
Version:        1.0.0
Release:        1%{?dist}
Summary:        Utilitário de exemplo do LinuxPro

License:        MIT
URL:            https://www.linuxpro.com.br
Source0:        %{name}-%{version}.tar.gz

BuildRequires:  gcc, make
Requires:       ca-certificates

%description
Descrição longa do pacote. Pode ter vários
parágrafos, sem a regra do espaço do Debian.

%prep
%setup -q                # desempacota o Source0 em BUILD/

%build
make %{?_smp_mflags}     # compila com paralelismo

%install
rm -rf %{buildroot}
install -D -m 0755 meuapp %{buildroot}%{_bindir}/meuapp
install -D -m 0644 meuapp.conf %{buildroot}%{_sysconfdir}/meuapp/meuapp.conf
install -D -m 0644 meuapp.1 %{buildroot}%{_mandir}/man1/meuapp.1

%files
%license LICENSE
%{_bindir}/meuapp
%config(noreplace) %{_sysconfdir}/meuapp/meuapp.conf
%{_mandir}/man1/meuapp.1*

%changelog
* Sat Sep 06 2026 Nilton Oliveira  - 1.0.0-1
- Primeira versao do pacote

Points worth highlighting:

  • %{?dist} in Release becomes .el9, .fc42 etc. — the same spec works for multiple distros
  • %config(noreplace) is the conffiles of the RPM world: an upgrade doesn't overwrite the edited config (the new one arrives as .rpmnew)
  • Always use path macros (%{_bindir}, %{_sysconfdir}, %{_unitdir}) instead of fixed paths
  • Everything that %install creates must be listed in %files — leftovers cause a build error (by design)

Building the package

# fonte na pasta certa
tar czf ~/rpmbuild/SOURCES/meuapp-1.0.0.tar.gz meuapp-1.0.0/

# build completo: binário + source RPM
rpmbuild -ba ~/rpmbuild/SPECS/meuapp.spec

ls ~/rpmbuild/RPMS/x86_64/   # meuapp-1.0.0-1.el9.x86_64.rpm
ls ~/rpmbuild/SRPMS/         # meuapp-1.0.0-1.el9.src.rpm

Install and validate with rpmlint (RPM's “lintian”):

rpmlint ~/rpmbuild/RPMS/x86_64/meuapp-*.rpm
sudo dnf install ~/rpmbuild/RPMS/x86_64/meuapp-1.0.0-1.el9.x86_64.rpm
rpm -ql meuapp
sudo dnf remove meuapp

Installation scripts and system user

The equivalents of postinst/prerm are %pre, %post, %preun and %postun. A system user, for example:

%pre
getent passwd meuapp >/dev/null || 
  useradd --system --no-create-home --shell /sbin/nologin meuapp
exit 0

Systemd services have ready-made macros — install the unit in %{_unitdir} and use:

BuildRequires: systemd-rpm-macros

%post
%systemd_post meuapp.service

%preun
%systemd_preun meuapp.service

%postun
%systemd_postun_with_restart meuapp.service

Automatic dependencies

RPM scans binaries itself: linked libraries become Requires automatically (e.g.: libssl.so.3()(64bit)). You manually declare only what it can't see — binaries called through exec, data files, etc. Static Go/Rust binaries get through with almost no dependencies.

Clean builds with mock

Compiling on your “dirty” machine hides missing build dependencies. The mock builds the package in a disposable chroot of the target distro — that's how Fedora/EPEL builds everything:

sudo dnf install mock
sudo usermod -aG mock $USER && newgrp mock
mock -r rocky-9-x86_64 ~/rpmbuild/SRPMS/meuapp-1.0.0-1.el9.src.rpm
# resultado em /var/lib/mock/rocky-9-x86_64/result/

With an SRPM and mock, you generate the same package for Rocky 9, Fedora 42 and openSUSE by changing only the -r.

GPG signing

gpg --gen-key   # se ainda não tem chave
echo "%_gpg_name Nilton Oliveira " >> ~/.rpmmacros
rpm --addsign ~/rpmbuild/RPMS/x86_64/meuapp-*.rpm
rpm -K ~/rpmbuild/RPMS/x86_64/meuapp-*.rpm   # verifica

Your own dnf repository

sudo dnf install createrepo_c
mkdir -p /srv/repo/el9/x86_64
cp ~/rpmbuild/RPMS/x86_64/*.rpm /srv/repo/el9/x86_64/
createrepo_c /srv/repo/el9/x86_64/

Serve it with nginx and, on the client, a .repo does the job:

cat | sudo tee /etc/yum.repos.d/meurepo.repo << 'EOF'
[meurepo]
name=Meu Repositorio
baseurl=https://repo.example.com/el9/$basearch/
enabled=1
gpgcheck=1
gpgkey=https://repo.example.com/chave.gpg
EOF
sudo dnf install meuapp

Published a new package? Run createrepo_c --update and clients receive it through dnf upgrade.

Shortcut: fpm

The same fpm from the previous post also spits out RPM — for an internal tool, it does the job in one command:

fpm -s dir -t rpm -n meuapp -v 1.0.0 --description "Meu utilitário" 
  --depends ca-certificates meuapp=/usr/bin/meuapp

Practical summary

  • Internal tool? fpm or a minimal spec — quick and sufficient
  • Publishing for real? complete spec + clean rpmlint + build in mock + signed RPM + repository with createrepo_c
  • Config with %config(noreplace), systemd with the macros %systemd_*, paths always through macros

With both posts in the pair — .deb and .rpm — you cover practically every Linux server in production. Questions or a tricky packaging case? Reach me on the contact page.