
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,.fc42etc. — the same spec works for multiple distros%config(noreplace)is theconffilesof 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
%installcreates 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?
fpmor a minimal spec — quick and sufficient - Publishing for real? complete spec + clean
rpmlint+ build inmock+ signed RPM + repository withcreaterepo_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.