🔐 Signed apt and dnf Repository Guide

A hands-on manual: turn the hangman packages into a repository that machines can install from and update by themselves, with every index signed and every client checking the signature. Includes the failures: a missing key, a modified package, an unsigned package, a modified index.

Video: Signed apt and dnf Repository Guide (Sysadmin video)
Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
sudo apt install cmd-hangman        # Ubuntu, from your repository
sudo dnf install cmd-hangman        # Rocky Linux, from the same repository

The earlier guides in this series built the hangman game as a .deb and an .rpm, and installed the files by hand. This guide puts them in a repository: a web folder with the packages and a signed index, so that apt and dnf can fetch the game, check who made it, and upgrade it when a new version is published.

You will make a signing key, sign the .rpm files, build the apt and dnf indexes and sign them, serve everything with nginx, set up an Ubuntu and a Rocky Linux client, publish an update with a short script, and then tamper with the repository on purpose to see what the clients say. The last section is about looking after the key, which is the only thing in the whole set-up that cannot be replaced.

Every example ran for real, as an ordinary user with sudo: a server (Ubuntu 24.04, nginx 1.24, gnupg 2.4.4, dpkg-dev, apt-utils, createrepo-c, rpm 4.18) and two clients, Ubuntu 24.04 and Rocky Linux 9.8 (dnf 4.14), on one network, with the server answering to the name repo.lab. The packages come from fpm, as in the fpm guide, in two versions, 1.0.0 and 1.0.1.

What a repository is, and what signing adds

So far in this series a package has been a file that you copy to a machine and install by hand. That works for one machine. For ten machines, or for a package that gets a new version every month, you want the machines to fetch it themselves: apt install cmd-hangman, and later apt upgrade. For that, the packages must live in a repository: a web folder with the package files and an index that lists them, which the package manager downloads and reads.

A repository is also the point where trust is decided. Anyone who can change the files on the web server (or sit between the server and the machine) can change the packages that your machines install as root. Signing is the defence. You make a key pair; the private key never leaves the publisher and signs the index; the public key goes to every client, which then refuses anything the private key did not sign. The two formats sign slightly different things, and the guide shows both:

  .deb / apt     the index (the Release file) is signed, and it holds the checksums of everything else, so the packages are protected through the index
  .rpm / dnf     each .rpm file is signed (gpgcheck), and the index (repomd.xml) is signed too (repo_gpgcheck)

This guide builds one signed repository that serves the hangman game to Ubuntu (apt) and to Rocky Linux (dnf), step by step: the signing key, the signed packages, the two indexes, the nginx server, the client set-up on both systems, an update, and what happens when something is tampered with. It uses plain command-line tools that are in the standard repositories (gpg, dpkg-scanpackages, apt-ftparchive, createrepo_c, rpmsign), so there is nothing to install on the clients beyond a key and a few lines of configuration.

The lab, and two versions of the package

The lab has three machines on one network: a server called repo.lab (Ubuntu 24.04 with nginx, where you work as the user tc), a client with Ubuntu 24.04, and a client with Rocky Linux 9.8. In the examples the server's address is the name repo.lab, which every machine resolves; use your own host name. On a real server nginx runs as a service (sudo systemctl enable --now nginx); the lab machines are containers without systemd, so nginx is started by hand.

The packages are the hangman game from the earlier guides, built with fpm as in the fpm guide. To have something to update later, build two versions: 1.0.0 now, and 1.0.1, the same files with a new version number, which is enough to see how a repository delivers an update. The builds run on a machine with fpm installed:

$ cat .fpm
-s dir
-n cmd-hangman
-a all
--category utils
--license CC0
--url https://techcommune.org
--maintainer "TechCommune Packages <packages@techcommune.org>"
--description "Linux command hangman for the terminal"
-d python3
$ chmod 755 cmd-hangman
$ gzip -9n -c cmd-hangman.1 > cmd-hangman.1.gz
$ for v in 1.0.0 1.0.1; do fpm -t deb -v $v cmd-hangman=/usr/bin/cmd-hangman cmd-hangman.1.gz=/usr/share/man/man1/cmd-hangman.1.gz; fpm -t rpm -a noarch -v $v cmd-hangman=/usr/bin/cmd-hangman cmd-hangman.1.gz=/usr/share/man/man1/cmd-hangman.1.gz; done 2>&1 | sed -n 's/.*"Created package", :path=>\(.*\)}/Created package: \1/p'
Created package: "cmd-hangman_1.0.0_all.deb"
Created package: "cmd-hangman-1.0.0-1.noarch.rpm"
Created package: "cmd-hangman_1.0.1_all.deb"
Created package: "cmd-hangman-1.0.1-1.noarch.rpm"
$ ls *.deb *.rpm
cmd-hangman-1.0.0-1.noarch.rpm
cmd-hangman-1.0.1-1.noarch.rpm
cmd-hangman_1.0.0_all.deb
cmd-hangman_1.0.1_all.deb

Copy the four files to the server, into a folder called incoming (scp in real life), and install the tools there. gnupg makes and uses keys; dpkg-dev and apt-utils make the apt index; createrepo-c makes the dnf index; rpm brings rpmsign for signing .rpm files; nginx serves it all:

$ sudo apt-get update -qq
$ sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y nginx gnupg dpkg-dev apt-utils createrepo-c rpm curl 2>&1 | tail -1
done.
$ ls incoming
cmd-hangman-1.0.0-1.noarch.rpm
cmd-hangman-1.0.1-1.noarch.rpm
cmd-hangman_1.0.0_all.deb
cmd-hangman_1.0.1_all.deb

Make the signing key

The signing key is the heart of the repository. Make it on the publishing machine, as the user who will publish. This command makes an RSA key with no expiry date, whose name is what the clients will see. The lab key has an empty passphrase so that the examples run without a prompt; a real key gets a passphrase, and a few more precautions, which are listed near the end:

$ gpg --batch --pinentry-mode loopback --passphrase '' --quick-generate-key "TechCommune Packages <packages@techcommune.org>" rsa3072 sign never 2>&1 | grep -v -E "^gpg: (directory|keybox|trustdb|checking|marginals|depth)"
gpg: /home/tc/.gnupg/trustdb.gpg: trustdb created
gpg: revocation certificate stored as '/home/tc/.gnupg/openpgp-revocs.d/E0442EFD03E2D6073074FDE89A1E714EF9D107C6.rev'
$ gpg --list-keys --keyid-format long 2>/dev/null
/home/tc/.gnupg/pubring.kbx
---------------------------
pub   rsa3072/9A1E714EF9D107C6 2026-11-01 [SC]
      E0442EFD03E2D6073074FDE89A1E714EF9D107C6
uid                 [ultimate] TechCommune Packages <packages@techcommune.org>
$ gpg --fingerprint "TechCommune Packages"
pub   rsa3072 2026-11-01 [SC]
      E044 2EFD 03E2 D607 3074  FDE8 9A1E 714E F9D1 07C6
uid           [ultimate] TechCommune Packages <packages@techcommune.org>

The long string of 40 hexadecimal digits is the fingerprint: the identity of the key. Anybody who wants to check that a public key is yours compares its fingerprint with one that you published in another place, so print it somewhere your users can see it (your website, the README, the video). Now export the public half, in the armored text form, which is what the clients will download. The private half stays in ~/.gnupg and is never exported:

$ mkdir -p repo && gpg --armor --export "TechCommune Packages" > repo/key.asc
$ head -3 repo/key.asc
-----BEGIN PGP PUBLIC KEY BLOCK-----

mQGNBGrmg6UBDADdP/dw2SwQFuCO9QhRg0umB/X2iojDNkzLWzCg0hFLaobUlgAy

Sign the .rpm files

For .rpm packages the signature goes into the file itself. rpmsign adds it, using the key named in the macro file ~/.rpmmacros. Try it with only the key name in the macro file, which is the first thing everybody writes:

$ rpm -qpi cmd-hangman-1.0.0-1.noarch.rpm | grep Signature
Signature   : (none)
$ cat ~/.rpmmacros
%_gpg_name TechCommune Packages
$ rpmsign --addsign cmd-hangman-1.0.0-1.noarch.rpm 2>&1 | grep -v GPG_TTY
error: Could not exec gpg: No such file or directory
error: gpg exec failed (1)
cmd-hangman-1.0.0-1.noarch.rpm:

On Debian and Ubuntu the rpm program does not know where gpg is, so it cannot run it. Add the path in a second macro line and try again:

$ cat ~/.rpmmacros
%_gpg_name TechCommune Packages
%__gpg /usr/bin/gpg
$ rpmsign --addsign *.rpm 2>&1 | grep -v GPG_TTY
cmd-hangman-1.0.0-1.noarch.rpm:
cmd-hangman-1.0.1-1.noarch.rpm:
$ rpm -qpi cmd-hangman-1.0.0-1.noarch.rpm | grep Signature
warning: cmd-hangman-1.0.0-1.noarch.rpm: Header V4 RSA/SHA512 Signature, key ID f9d107c6: NOKEY
Signature   : RSA/SHA512, Sun Nov  1 00:11:18 2026, Key ID 9a1e714ef9d107c6

Before the signature, the first query said (none). Now it names the key id, the last 16 digits of the fingerprint (the NOKEY warning only means that rpm does not know the key yet). To check a signature, rpm needs the public key in its own key store; until it is imported, the check fails, and afterwards it passes. On Debian and Ubuntu rpm keeps its database in the home folder of the user (~/.rpmdb), so import the key as the same user, without sudo; on Rocky or RHEL, where rpm is the system's own package manager, the import is done by root:

$ rpm -K cmd-hangman-1.0.0-1.noarch.rpm
cmd-hangman-1.0.0-1.noarch.rpm: digests SIGNATURES NOT OK
$ rpmkeys --import ~/repo/key.asc
$ rpm -K cmd-hangman-1.0.0-1.noarch.rpm
cmd-hangman-1.0.0-1.noarch.rpm: digests signatures OK

Build the apt repository

An apt repository has a fixed layout. The packages go in a pool folder; the index files go under dists/SUITE/COMPONENT/binary-ARCH/; the suite here is called stable and the component main. Make the web root and the folders (the web root belongs to tc, because tc publishes), and put the first version of the .deb in the pool:

$ sudo install -d -o tc -g tc /var/www/repo
$ mkdir -p /var/www/repo/apt/pool/main /var/www/repo/apt/dists/stable/main/binary-amd64 /var/www/repo/rpm/el9
$ cp incoming/cmd-hangman_1.0.0_all.deb /var/www/repo/apt/pool/main/
$ cp repo/key.asc /var/www/repo/key.asc

The Packages file is the list of packages: one record each, with the file name, size and checksums. dpkg-scanpackages writes it from the pool. --arch all keeps the packages that work on every architecture. The index is also kept compressed, because apt asks for the .gz as well:

$ cd /var/www/repo/apt && dpkg-scanpackages --arch all pool/main > dists/stable/main/binary-amd64/Packages 2>/dev/null
$ cd /var/www/repo/apt && gzip -9 -k -f dists/stable/main/binary-amd64/Packages
$ cat /var/www/repo/apt/dists/stable/main/binary-amd64/Packages
Package: cmd-hangman
Version: 1.0.0
Architecture: all
Maintainer: TechCommune Packages <packages@techcommune.org>
Installed-Size: 2
Depends: python3
Filename: pool/main/cmd-hangman_1.0.0_all.deb
Size: 2400
MD5sum: 918a5b86451f3915c31ffe6c449f4e5f
SHA1: 462ab0e125d6605ff033a52353e0412695c7be5c
SHA256: 4509deeabc718695d1900028c8969920381992aa2e5a9fad455edca5c64aa6b0
Section: utils
Priority: optional
Homepage: https://techcommune.org
Description: Linux command hangman for the terminal
License: CC0
Vendor: none

The Release file describes the whole suite and lists the checksum of every index file. This is the file that gets signed, and it protects everything below it. apt-ftparchive writes it, using a small configuration file for the names. The architecture the client asks for is amd64; the "all" packages are inside its list. Write the Release file to a temporary name first and move it into place, so that it cannot list itself:

$ cat repo/apt-release.conf
APT::FTPArchive::Release::Origin "TechCommune";
APT::FTPArchive::Release::Label "TechCommune";
APT::FTPArchive::Release::Suite "stable";
APT::FTPArchive::Release::Codename "stable";
APT::FTPArchive::Release::Architectures "amd64";
APT::FTPArchive::Release::Components "main";
APT::FTPArchive::Release::Description "TechCommune packages";
$ cd /var/www/repo/apt && apt-ftparchive -c ~/repo/apt-release.conf release dists/stable > /tmp/Release.new && mv /tmp/Release.new dists/stable/Release
$ cat /var/www/repo/apt/dists/stable/Release
Architectures: amd64
Codename: stable
Components: main
Date: Sun, 01 Nov 2026 00:11:19 +0000
Description: TechCommune packages
Label: TechCommune
Origin: TechCommune
Suite: stable
MD5Sum:
 27b3473f00eb8004039a672b6b74a095              514 main/binary-amd64/Packages
 3b19441d3d7b3df1a4e07b672afe997c              385 main/binary-amd64/Packages.gz
SHA1:
 4aec5b9f7f30af54a3711f1216a79dd2931cd81a              514 main/binary-amd64/Packages
 716da8ea6a9a9392ba479d7b8af126b7ac1479b8              385 main/binary-amd64/Packages.gz
SHA256:
 282a822c6d9d4520c0f7a25f3bb2853b2f31d7bab2522743e0bc9b9db285519e              514 main/binary-amd64/Packages
 25ebdf136943d75235cd09f48db4b1c188cf00e50c7ebf7668b0e115f52d36e4              385 main/binary-amd64/Packages.gz
SHA512:
 a3c59ecba0686e52a41d207fc4f913288a50316930466ce5671ac23a3a90fda5114c95aeb638488be25fb52deb53da539506e76eb495dbfa48014cc5306265b5              514 main/binary-amd64/Packages
 7cf708227206e0566cef3edef77a5c192d8437123083bb956a24cebb6a6fb879a1441fb7de79fc1bfa1f6526fd51765675d0101beaa9306bc86ed73b8ea58f43              385 main/binary-amd64/Packages.gz

Now sign it two ways, because apt versions and tools look for either: InRelease is the same text with the signature wrapped around it (a clear-signed file), and Release.gpg is a detached signature next to the plain Release:

$ cd /var/www/repo/apt && gpg --batch --yes -u "TechCommune Packages" --clearsign -o dists/stable/InRelease dists/stable/Release
$ cd /var/www/repo/apt && gpg --batch --yes -u "TechCommune Packages" -abs -o dists/stable/Release.gpg dists/stable/Release
$ ls -l /var/www/repo/apt/dists/stable
total 16
-rw-r--r-- 1 tc tc 1862 Nov  1 00:11 InRelease
-rw-r--r-- 1 tc tc 1118 Nov  1 00:11 Release
-rw-r--r-- 1 tc tc  695 Nov  1 00:11 Release.gpg
drwxr-xr-x 3 tc tc 4096 Nov  1 00:11 main

Build the dnf repository

A dnf repository is simpler to lay out: a folder of .rpm files and a repodata folder next to them, which createrepo_c writes. Put the signed .rpm in the folder and make the metadata. repomd.xml, inside repodata, is the file that lists all the others by checksum, so it plays the part of the Release file, and it is signed the same way, with a detached signature next to it called repomd.xml.asc:

$ cp incoming/cmd-hangman-1.0.0-1.noarch.rpm /var/www/repo/rpm/el9/
$ cd /var/www/repo/rpm/el9 && createrepo_c . 2>&1 | tail -2
Pool started (with 5 workers)
Pool finished
$ cd /var/www/repo/rpm/el9 && gpg --batch --yes -u "TechCommune Packages" --detach-sign --armor repodata/repomd.xml
$ ls /var/www/repo/rpm/el9 /var/www/repo/rpm/el9/repodata
/var/www/repo/rpm/el9:
cmd-hangman-1.0.0-1.noarch.rpm
repodata

/var/www/repo/rpm/el9/repodata:
1cc42c6e95f29fa9ba9c65af1cdd25db4914f3dcd1e13c7848f624cfedc12b3f-primary.sqlite.bz2
22bf41ae9502bae0275b882df7d45fcf257c183ea110b19c9fc857a0a8be1e68-filelists.xml.gz
64ed9ae69142bc86ad51baaf7e8711d3335dc34a1f8f05fee51baf9473991891-filelists.sqlite.bz2
ca4eb10559d29e5db9afe3b3a9cb92240a22b9a96af56c2a1f7a79aaf16c1fad-other.xml.gz
d10b3f15f2e61aa5b26eea8e266d22c955270edc5fec04bc29aa12e9722150ca-primary.xml.gz
fed1850636c0155bd986a5e68246c611ef5e66cbf67da4fc920d99f0c2b22bd7-other.sqlite.bz2
repomd.xml
repomd.xml.asc

The folder is called el9 because the packages are for Enterprise Linux 9 (Rocky 9, RHEL 9, Alma 9); a repository for another release would get its own folder. The signature file is the only thing in repodata that createrepo_c did not write.

Serve it with nginx

A repository is only static files, so any web server will do. This nginx configuration serves /var/www/repo and lists folders when somebody opens them in a browser (autoindex), which helps with debugging. The default site is removed so that this one answers. Check the configuration, start nginx, and ask for the index and the key like a client would:

$ cat repo/repo.conf
server {
    listen 80 default_server;
    server_name repo.lab;
    root /var/www/repo;
    autoindex on;
}
$ sudo cp repo/repo.conf /etc/nginx/conf.d/repo.conf
$ sudo rm -f /etc/nginx/sites-enabled/default
$ sudo nginx -t 2>&1
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo nginx
$ curl -sI http://repo.lab/apt/dists/stable/InRelease | head -3
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Sun, 01 Nov 2026 00:11:21 GMT
$ curl -s http://repo.lab/key.asc | head -2
-----BEGIN PGP PUBLIC KEY BLOCK-----

The repository is up. Everything so far was at the publisher; from here on it is the clients' turn. In real life serve the repository over https: the signatures protect the packages even over plain http, but https hides what you install and, more importantly, protects the first download of the key, which is the one thing that the signatures cannot protect (more on that below).

Use it from Ubuntu

On an Ubuntu client add the repository as a source and run apt update. Without the key, apt does the right thing: it reads the index, cannot check the signature, and refuses the repository:

$ sudo sh -c 'echo "deb http://repo.lab/apt stable main" > /etc/apt/sources.list.d/techcommune.list'
$ sudo apt-get update 2>&1 | grep -E "repo.lab|^E:|^W:"
Get:1 http://repo.lab/apt stable InRelease [1862 B]
Err:1 http://repo.lab/apt stable InRelease
W: GPG error: http://repo.lab/apt stable InRelease: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 9A1E714EF9D107C6
E: The repository 'http://repo.lab/apt stable InRelease' is not signed.

Now install the key. The file the server offers is in the armored text form; apt keeps keys in the binary form, so gpg --dearmor converts it. Put it in /etc/apt/keyrings, which is the place for keys that belong to one repository. Before you trust it, look at its fingerprint and compare with the one that the publisher printed:

$ curl -fsS http://repo.lab/key.asc | gpg --show-keys --with-fingerprint
gpg: directory '/home/tc/.gnupg' created
gpg: keybox '/home/tc/.gnupg/pubring.kbx' created
pub   rsa3072 2026-11-01 [SC]
      E044 2EFD 03E2 D607 3074  FDE8 9A1E 714E F9D1 07C6
uid                      TechCommune Packages <packages@techcommune.org>
$ sudo install -d -m 755 /etc/apt/keyrings
$ curl -fsS http://repo.lab/key.asc | sudo gpg --dearmor -o /etc/apt/keyrings/techcommune.gpg
$ ls -l /etc/apt/keyrings
total 4
-rw-r--r-- 1 root root 917 Nov  1 00:11 techcommune.gpg

Then rewrite the source so that it names the key: signed-by makes apt accept this repository only when it is signed by this key, and no other source can use it. (The old apt-key command, which trusted a key for every repository, is deprecated; do not use it.)

$ sudo sh -c 'echo "deb [signed-by=/etc/apt/keyrings/techcommune.gpg] http://repo.lab/apt stable main" > /etc/apt/sources.list.d/techcommune.list'
$ cat /etc/apt/sources.list.d/techcommune.list
deb [signed-by=/etc/apt/keyrings/techcommune.gpg] http://repo.lab/apt stable main
$ sudo apt-get update 2>&1 | grep -E "repo.lab|^E:|^W:"
Get:1 http://repo.lab/apt stable InRelease [1862 B]
Get:2 http://repo.lab/apt stable/main amd64 Packages [385 B]
$ apt-cache policy cmd-hangman
cmd-hangman:
  Installed: (none)
  Candidate: 1.0.0
  Version table:
     1.0.0 500
        500 http://repo.lab/apt stable/main amd64 Packages
$ sudo apt-get install -y cmd-hangman 2>&1 | grep -E "^(Setting up|Unpacking) cmd-hangman"
Unpacking cmd-hangman (1.0.0) ...
Setting up cmd-hangman (1.0.0) ...
$ dpkg -s cmd-hangman | grep -E "^(Package|Version|Status)"
Package: cmd-hangman
Status: install ok installed
Version: 1.0.0

From now on the game is an ordinary package of this machine: apt upgrade will find new versions, and apt remove removes it.

Use it from Rocky Linux

On Rocky (and RHEL, Alma, Fedora) a repository is a file in /etc/yum.repos.d. gpgcheck=1 makes dnf verify the signature of every package, repo_gpgcheck=1 makes it verify the signature of the index, and gpgkey says where the public key is:

$ cat repo.tmp
[techcommune]
name=TechCommune packages
baseurl=http://repo.lab/rpm/el9
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=http://repo.lab/key.asc
$ sudo cp repo.tmp /etc/yum.repos.d/techcommune.repo
The first time dnf meets the key it shows who it belongs to and asks whether to import it. This is the moment to compare the fingerprint with the publisher's; answering n refuses the key, and without the key dnf cannot check the signed index, so it stops with an error:

$ echo n | sudo dnf --disablerepo="*" --enablerepo=techcommune install cmd-hangman 2>&1 | grep -v -E "^(Last metadata|TechCommune packages)"
Importing GPG key 0xF9D107C6:
 Userid     : "TechCommune Packages <packages@techcommune.org>"
 Fingerprint: E044 2EFD 03E2 D607 3074 FDE8 9A1E 714E F9D1 07C6
 From       : http://repo.lab/key.asc
Is this ok [y/N]: TechCommune packages                             21 kB/s | 695  B     00:00
Error: Failed to download metadata for repo 'techcommune': repomd.xml GPG signature verification error: Bad GPG signature

The fingerprint on that screen is the same one that gpg printed at the publisher. With the check done, answer yes, here with -y, which is safe only because you have just compared. The package installs, and rpm keeps the key in its own store:

$ sudo dnf -y --disablerepo="*" --enablerepo=techcommune install cmd-hangman 2>&1 | grep -E "Importing|Key imported|Installing|Installed|cmd-hangman-"
Importing GPG key 0xF9D107C6:
Installing:
Installed size: 2.4 k
cmd-hangman-1.0.0-1.noarch.rpm                  3.5 MB/s | 7.7 kB     00:00
Importing GPG key 0xF9D107C6:
Key imported successfully
  Installing       : cmd-hangman-1.0.0-1.noarch                             1/1
  Running scriptlet: cmd-hangman-1.0.0-1.noarch                             1/1
  Verifying        : cmd-hangman-1.0.0-1.noarch                             1/1
Installed:
  cmd-hangman-1.0.0-1.noarch
$ rpm -q cmd-hangman
cmd-hangman-1.0.0-1.noarch
$ rpm -qa gpg-pubkey --qf "%{name}-%{version} %{summary}\n" | grep TechCommune
gpg-pubkey-f9d107c6 TechCommune Packages <packages@techcommune.org> public key
$ rpm -qi cmd-hangman | grep -E "^(Name|Version|Signature)"
Name        : cmd-hangman
Version     : 1.0.0
Signature   : RSA/SHA512, Sun Nov  1 00:11:18 2026, Key ID 9a1e714ef9d107c6

Play one round to see that it works (the word is fixed for this page):

$ cmd-hangman
=== cmd-hangman by TechCommune ===





_______

Command: _ _ _ _
Hint:    search text for a pattern
Guess a letter: g





_______

Command: g _ _ _
Hint:    search text for a pattern
Guess a letter: r





_______

Command: g r _ _
Hint:    search text for a pattern
Guess a letter: e





_______

Command: g r e _
Hint:    search text for a pattern
Guess a letter: p





_______

Command: g r e p
Hint:    search text for a pattern

You got it! The command was: grep

Play again? (y/n) n
Happy hacking!

Publish an update with one script

The steps that follow a new package are always the same: scan, write the Release file, sign it twice, build the dnf metadata, sign it. Put them in a script so that nobody forgets one. The script goes in ~/bin, and uses the same names as before:

$ cat bin/publish-repo
#!/bin/sh
# publish-repo: rebuild and sign the apt and dnf indexes in /var/www/repo
set -e
KEY="TechCommune Packages"
cd /var/www/repo/apt
dpkg-scanpackages --arch all pool/main > dists/stable/main/binary-amd64/Packages 2>/dev/null
gzip -9 -k -f dists/stable/main/binary-amd64/Packages
apt-ftparchive -c ~/repo/apt-release.conf release dists/stable > /tmp/Release.new
mv /tmp/Release.new dists/stable/Release
gpg --batch --yes -u "$KEY" --clearsign -o dists/stable/InRelease dists/stable/Release
gpg --batch --yes -u "$KEY" -abs -o dists/stable/Release.gpg dists/stable/Release
cd /var/www/repo/rpm/el9
createrepo_c --update . > /dev/null
gpg --batch --yes -u "$KEY" --detach-sign --armor repodata/repomd.xml
echo "published"
$ chmod 755 bin/publish-repo

Now release version 1.0.1: copy the new files into the two pools (the .rpm was signed in the same rpmsign run as 1.0.0), and run the script:

$ cp incoming/cmd-hangman_1.0.1_all.deb /var/www/repo/apt/pool/main/
$ cp incoming/cmd-hangman-1.0.1-1.noarch.rpm /var/www/repo/rpm/el9/
$ ~/bin/publish-repo
published
$ ls /var/www/repo/apt/pool/main /var/www/repo/rpm/el9
/var/www/repo/apt/pool/main:
cmd-hangman_1.0.0_all.deb
cmd-hangman_1.0.1_all.deb

/var/www/repo/rpm/el9:
cmd-hangman-1.0.0-1.noarch.rpm
cmd-hangman-1.0.1-1.noarch.rpm
repodata

The clients have to do nothing special. Ubuntu learns about the new version with apt update, and upgrades like any other package:

$ sudo apt-get update 2>&1 | grep -E "repo.lab|^E:|^W:"
Get:1 http://repo.lab/apt stable InRelease [2234 B]
Get:2 http://repo.lab/apt stable/main amd64 Packages [386 B]
$ apt list --upgradable 2>/dev/null | grep cmd-hangman
cmd-hangman/stable 1.0.1 all [upgradable from: 1.0.0]
$ sudo apt-get install -y --only-upgrade cmd-hangman 2>&1 | grep -E "^(Unpacking|Setting up) cmd-hangman"
Unpacking cmd-hangman (1.0.1) over (1.0.0) ...
Setting up cmd-hangman (1.0.1) ...
$ dpkg -s cmd-hangman | grep ^Version
Version: 1.0.1

Rocky asks with dnf check-update (exit status 100 means that there are updates). dnf keeps the repository index in a cache and trusts it for a while, so tell it to download a fresh one with --refresh (apt update is that step on Ubuntu). Then upgrade:

$ sudo dnf --refresh --disablerepo="*" --enablerepo=techcommune check-update 2>&1 | tail -2

cmd-hangman.noarch                      1.0.1-1                      techcommune
$ sudo dnf -y --refresh --disablerepo="*" --enablerepo=techcommune upgrade 2>&1 | grep -E "Upgrading|Upgraded|^ +cmd-hangman-"
Upgrading:
  Upgrading        : cmd-hangman-1.0.1-1.noarch                             1/2
Upgraded:
  cmd-hangman-1.0.1-1.noarch
$ rpm -q cmd-hangman
cmd-hangman-1.0.1-1.noarch

In this guide's lab every update is a new version of the same package; a real repository would keep the older versions in the pool too (dpkg-scanpackages lists only the newest one of each package by default; add --multiversion to list them all).

What the clients do when something is wrong

The point of all that signing is the day somebody tampers with the repository. Three experiments on the server, each one undone afterwards, show what the clients say.

First, a modified package. Change a byte of the .deb in the pool after the index was signed, without rebuilding the index. apt checks the file against the size and checksums in the Packages file, which the signature protects, and refuses it:

$ echo tamper >> /var/www/repo/apt/pool/main/cmd-hangman_1.0.1_all.deb
$ sudo apt-get install -y cmd-hangman 2>&1 | grep -E "^Err|mismatch|unexpected|^E:"
Err:1 http://repo.lab/apt stable/main amd64 cmd-hangman all 1.0.1
  File has unexpected size (2407 != 2400). Mirror sync in progress? [IP: 172.26.0.2 80]
E: Failed to fetch http://repo.lab/apt/pool/main/cmd-hangman_1.0.1_all.deb  File has unexpected size (2407 != 2400). Mirror sync in progress? [IP: 172.26.0.2 80]
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?
$ cp /tmp/good.deb /var/www/repo/apt/pool/main/cmd-hangman_1.0.1_all.deb

Second, a package that is not signed. Replace the .rpm in the pool with an unsigned build of the same name, rebuild the metadata honestly (so the index is correctly signed) and sign repomd.xml. The index is fine; the package is not signed by the key, and dnf refuses it:

$ rpm -qpi /tmp/unsigned.rpm | grep Signature
Signature   : (none)
$ cp /tmp/unsigned.rpm /var/www/repo/rpm/el9/cmd-hangman-1.0.1-1.noarch.rpm && ~/bin/publish-repo
published
$ sudo dnf --disablerepo="*" --enablerepo=techcommune install -y cmd-hangman 2>&1 | grep -E "not signed|GPG check|^Error"
Package cmd-hangman-1.0.1-1.noarch.rpm is not signed
Error: GPG check FAILED

Third, a modified index. Change repomd.xml after it was signed. dnf checks the detached signature before it trusts anything else in the repository:

$ cp /tmp/good.rpm /var/www/repo/rpm/el9/cmd-hangman-1.0.1-1.noarch.rpm && ~/bin/publish-repo
published
$ echo " " >> /var/www/repo/rpm/el9/repodata/repomd.xml
$ sudo dnf --disablerepo="*" --enablerepo=techcommune makecache 2>&1 | tail -2
TechCommune packages                             24 kB/s | 695  B     00:00
Error: Failed to download metadata for repo 'techcommune': repomd.xml GPG signature verification error: Bad GPG signature

Put the repository back by publishing again, and the client recovers without any other action:

$ ~/bin/publish-repo
published
$ sudo dnf -y --disablerepo="*" --enablerepo=techcommune install cmd-hangman 2>&1 | grep -E "Installed|cmd-hangman-|^Error"
Installed size: 2.4 k
cmd-hangman-1.0.1-1.noarch.rpm                  3.3 MB/s | 7.7 kB     00:00
  Installing       : cmd-hangman-1.0.1-1.noarch                             1/1
  Running scriptlet: cmd-hangman-1.0.1-1.noarch                             1/1
  Verifying        : cmd-hangman-1.0.1-1.noarch                             1/1
Installed:
  cmd-hangman-1.0.1-1.noarch
$ rpm -q cmd-hangman
cmd-hangman-1.0.1-1.noarch

Keep the key safe, and the rest of the real world

Everything above is only as good as the private key. The lab used an empty passphrase to keep the examples short; for a real repository:

1. Give the key a passphrase, and keep the private key on a machine that is not the web server. The server only needs the public files; signing can be done elsewhere and the result copied up.
2. Back up ~/.gnupg to somewhere offline, and the revocation certificate that gpg wrote when it made the key (openpgp-revocs.d). If the key is ever lost or stolen, the revocation certificate is how you tell the world to stop trusting it.
3. Consider a signing subkey with an expiry date, and a master key that stays offline. gpg --edit-key does this; the clients only need the public key, which a rotation replaces.
4. Publish the fingerprint where users can read it through a different path than the key download itself: your website, the README, the video. A user who compares the two has checked that the key is yours, whoever served it.
5. Serve the repository over https. The signatures protect the packages whatever the transport, but the first download of the key is the one that nothing protects, and https plus the published fingerprint covers it.
6. Never use apt-key, trusted=yes on a source line, or gpgcheck=0 to make an error go away. They turn the checks off, which is what a tampering attacker would want you to do.

The .deb and .rpm sides, compared:

                          apt (.deb)                                   dnf (.rpm)
what is signed            the Release file (InRelease, Release.gpg)    each .rpm, and repomd.xml
package checksums         in Packages, protected by the Release        in the .rpm's own signature, and in the metadata
tools on the server       dpkg-scanpackages, apt-ftparchive, gpg       createrepo_c, rpmsign, gpg
key location on clients   /etc/apt/keyrings/NAME.gpg + signed-by       rpm's key store, imported from gpgkey=
source file               /etc/apt/sources.list.d/NAME.list            /etc/yum.repos.d/NAME.repo
refresh the index         apt update                                   dnf makecache, or --refresh on any command
upgrade                   apt upgrade                                  dnf upgrade
checks you can turn off   trusted=yes (do not)                         gpgcheck=0, repo_gpgcheck=0 (do not)

This is the end of the packaging series so far: Python Game: Build Linux Command Hangman, Build a .deb Package, Build an .rpm Package, Java Game: Build Linux Command Hangman in Java, Package a Java App as a .deb and an .rpm, Side by Side, Build .deb and .rpm Packages with fpm, and this guide, which turns the packages into something that machines can install and update by themselves.