Why bother, when a tarball works?
A `curl | tar` install works fine the first time. The problem is the second time. Nothing tells your users a new version exists, nothing upgrades it alongside their other packages, and nothing removes it cleanly. Every user ends up inventing their own little update ritual.
A package repository moves all of that onto machinery the operating system already has. `apt-get upgrade` picks up your new release with everything else. Removal is `apt-get remove`. Provenance is a GPG signature the package manager checks automatically, without anyone having to think about it.
The cost is an afternoon of setup and a small amount of per-release work you can script. Here is the whole thing.
What a repository actually is
Both formats are just static files over HTTPS. There is no daemon and no database — which is exactly why a CDN serves them so well.
An APT repository has a `pool/` holding the `.deb` files and a `dists/<suite>/` tree of metadata. `Packages` lists every package with its size, hashes and path; `Release` lists the hashes of the `Packages` files; and the signature covers `Release`. That is the entire trust chain, and it matters later.
A YUM repository is flatter: the `.rpm` files sit in a directory next to `repodata/`, which holds `repomd.xml` and the compressed indexes it points at.
Repository layout
deb/
dists/stable/
Release InRelease Release.gpg
main/binary-amd64/Packages{,.gz}
main/binary-arm64/Packages{,.gz}
pool/main/y/yourtool/yourtool_1.0.0_amd64.deb
rpm/
yourtool-1.0.0-1.x86_64.rpm
repodata/
repomd.xml repomd.xml.asc
*-primary.xml.gz *-filelists.xml.gz *-other.xml.gz
Create a signing key
The key is the one part you cannot casually redo: if you lose it, every user has to import a new one. Generate it once, back up both the secret key and the revocation certificate, and keep the private half off any machine that does not need it.
Release scripts sign unattended, so a repository signing key normally has no passphrase — it is protected by file permissions and by living somewhere restricted, the same way a CI signing key is. Give it a real expiry (five years is common) and put the renewal date somewhere you will actually see it.
cat > key.batch <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign
Name-Real: example.com Package Signing
Name-Email: packages@example.com
Expire-Date: 5y
%commit
EOF
gpg --batch --gen-key key.batch && rm key.batch
# the public half — this is what users import
gpg --armor --export packages@example.com > example-com.gpg
# BACK THESE UP, offline:
# gpg --export-secret-keys --armor packages@example.com
# ~/.gnupg/openpgp-revocs.d/<fingerprint>.rev
Build and sign the APT repository
`apt-ftparchive` (from `apt-utils`) generates both metadata files. Two details matter.
Run it from the repository root, because the `Filename:` paths it writes into `Packages` are relative to wherever you invoke it. Get this wrong and apt will happily read your metadata, then 404 on every download.
Then publish the signature twice: `InRelease` is the modern inline-signed form and `Release.gpg` is a detached signature for older clients. Producing both costs nothing and avoids a whole class of support questions.
cd deb
for arch in amd64 arm64; do
mkdir -p "dists/stable/main/binary-$arch"
apt-ftparchive --arch "$arch" packages pool \
> "dists/stable/main/binary-$arch/Packages"
gzip -9cn "dists/stable/main/binary-$arch/Packages" \
> "dists/stable/main/binary-$arch/Packages.gz"
done
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=example.com \
-o APT::FTPArchive::Release::Suite=stable \
-o APT::FTPArchive::Release::Components=main \
-o APT::FTPArchive::Release::Architectures="amd64 arm64" \
release dists/stable > dists/stable/Release
gpg --batch --yes -abs -o dists/stable/Release.gpg dists/stable/Release
gpg --batch --yes --clearsign -o dists/stable/InRelease dists/stable/Release
RPM needs two signatures, not one
This is the step that catches almost everyone, and it caught us. APT and YUM verify packages in fundamentally different ways.
APT is a chain: the signature covers `Release`, which holds the hashes of `Packages`, which holds the hashes of every `.deb`. Sign the metadata and the packages are covered for free — individual `.deb` files are not signed at all.
RPM has two independent checks. `repo_gpgcheck=1` verifies `repomd.xml` through a detached `repomd.xml.asc`. But `gpgcheck=1` verifies a signature embedded inside each package, which nothing else provides. Sign only the metadata and dnf stops with `Package … is not signed / Error: GPG check FAILED`.
So run `rpmsign` on every `.rpm` — and run it before `createrepo_c`, because signing rewrites the files and any checksum recorded earlier is instantly wrong.
# rpmsign ships in the 'rpm' package on Debian/Ubuntu.
# rpm hardcodes %__gpg to /usr/bin/gpg2, which Debian/Ubuntu do not ship:
rpmsign --define "_gpg_name packages@example.com" \
--define "__gpg $(command -v gpg)" \
--addsign rpm/*.rpm
# fail loudly rather than publish something unsigned
for r in rpm/*.rpm; do
rpm -qpi "$r" | grep -qi '^Signature *: *(none)' \
&& { echo "UNSIGNED: $r"; exit 1; }
done
# only now build the indexes, then sign repomd.xml
createrepo_c --quiet rpm/
gpg --batch --yes --detach-sign --armor \
-o rpm/repodata/repomd.xml.asc rpm/repodata/repomd.xml
Serving it from a CDN — and the trap
A package repository is close to an ideal CDN workload: static files, downloaded far more often than they change, by users all over the world. Point the edge at your origin and the bandwidth problem disappears.
The trap is that repository metadata is internally consistent. Every file references hashes of other files. A cache that hands you a fresh `Release` and a stale `Packages` has not given you a slightly out-of-date repository — it has given you a broken one, and the error will not mention caching at all.
These are the three failures we hit, in a single publish:
• Stale `InRelease` with fresh `Packages` — apt reports a hash mismatch.
• Stale `.rpm` — dnf reports `Package … is not signed`, because signing rewrote the file and the cached copy is the older, unsigned one.
• Fresh `repomd.xml` with stale `repomd.xml.asc` — dnf reports `Bad PGP signature`. Both files changed; only one was purged.
The rule that falls out of this: a partial purge is worse than no purge. Purge every file the publish rewrote — the entry points, the hashed index files under `repodata/`, and the packages themselves. Or give metadata a very short TTL and let the packages cache for a long time, since their names change with every version.
# entry points
for p in /deb/dists/stable/InRelease \
/deb/dists/stable/Release \
/deb/dists/stable/Release.gpg \
/deb/dists/stable/main/binary-amd64/Packages \
/deb/dists/stable/main/binary-amd64/Packages.gz \
/rpm/repodata/repomd.xml \
/rpm/repodata/repomd.xml.asc; do
cdnctl purge --account "$ACC" --path "$p" --type exact
done
# the hashed indexes and the packages changed too
for f in rpm/repodata/* rpm/*.rpm; do
cdnctl purge --account "$ACC" \
--path "/rpm/$(basename "$f")" --type exact
done
Publishing: getting the files onto the CDN
A repository is only static files, so "publishing" means putting that directory tree somewhere the CDN can serve it. There are two shapes, and which one you want depends on whether you already run a web server.
**If you already have an origin** — any host serving HTTPS — put the CDN in front of it and publish by copying files there. `rsync` over SSH is enough; the edge fetches from the origin on the first request and caches from then on. This is what we do for cdn.com.tr, because the download host is the same machine that serves the site.
One warning from experience: sync the subdirectories, not the parent. An `rsync --delete` aimed at the directory that also holds your other downloads will cheerfully delete them.
**If you do not want to run an origin at all**, push the files straight into CDN storage. With cdn.com.tr you upload the tree and it is served from your CDN hostname — no web server to install, patch or keep alive. `cdnctl cp -r` uploads a whole directory, which is exactly the shape a repository has.
Either way, the base URL your users put in `sources.list` or the `.repo` file is just your CDN hostname plus the path you published to. And either way, finish with the purge step from the previous section — that is the part people forget.
# A) you have an origin: sync the subdirectories, never the parent
rsync -a --delete dist/repo/deb/ user@origin:/var/www/downloads/deb/
rsync -a --delete dist/repo/rpm/ user@origin:/var/www/downloads/rpm/
rsync -a dist/repo/example-com.gpg user@origin:/var/www/downloads/
# B) no origin: push straight into CDN storage
cdnctl cp -r dist/repo/deb <account_uuid>:/downloads/deb
cdnctl cp -r dist/repo/rpm <account_uuid>:/downloads/rpm
cdnctl files put --file dist/repo/example-com.gpg \
--target-path /downloads/example-com.gpg
# the base URL is simply your CDN hostname + that path:
# deb -> https://cdn.example.com/downloads/deb
# rpm -> https://cdn.example.com/downloads/rpm
Verify in a clean container
Do not verify on your own machine. It already trusts your key, it may hold a cached index, and it will cheerfully hide the exact problems your users are about to hit. A throwaway container is the only honest test — it is how we found both the missing package signature and the stale-cache failures.
Keep signature checking on while testing. Turning `gpgcheck` off to see whether the rest works is how a repository ends up shipping unsigned.
One tip for the Fedora side: pass `--disablerepo="*" --enablerepo="yourrepo"`. Otherwise dnf spends its time downloading Fedora's own metadata, and a slow or failed run tells you nothing about your repository.
# Debian / Ubuntu
docker run --rm debian:12 sh -c '
apt-get update -qq && apt-get install -y -qq curl gnupg ca-certificates
curl -fsSL https://example.com/example-com.gpg |
gpg --dearmor -o /usr/share/keyrings/example-com.gpg
echo "deb [signed-by=/usr/share/keyrings/example-com.gpg] \
https://example.com/deb stable main" \
> /etc/apt/sources.list.d/example.list
apt-get update && apt-get install -y yourtool && yourtool --version'
# Fedora / RHEL — only your repo, so the test is about your repo
docker run --rm fedora:40 sh -c '
printf "[example]\nbaseurl=https://example.com/rpm\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=https://example.com/example-com.gpg\n" \
> /etc/yum.repos.d/example.repo
dnf --disablerepo="*" --enablerepo="example" -y install yourtool
rpm -qi yourtool | grep Signature'
One more thing: disable self-update in packages
If your tool can update itself, that feature now conflicts with the package manager. A self-update overwrites a file `dpkg` or `rpm` owns, and their database keeps pointing at a version that is no longer on disk — so the next upgrade does something surprising.
The fix is small: stamp the build with how it was distributed, and have the self-updater step aside when a package manager installed it. Direct downloads keep updating themselves as before.
// installChannel is overridden at build time for packaged builds.
var installChannel = "direct"
func update() error {
if installChannel != "direct" {
return fmt.Errorf(
"installed via %s — upgrade it with that instead",
installChannel)
}
// ... normal self-update
}
// packaged builds:
// go build -ldflags "-X main.installChannel=deb"
Questions people ask
Do I have to sign the repository at all?
Technically no — apt and dnf can be told to skip verification. Practically yes. Users would have to disable a security check to install your software, which is a bad thing to ask for and a worse habit to teach. Signing costs one key and two commands.
Why did dnf say my package is not signed when I signed the metadata?
Because RPM checks two separate things. `repo_gpgcheck` covers `repomd.xml`; `gpgcheck` covers a signature embedded in each `.rpm`. You need `rpmsign --addsign` on the packages as well — and it has to run before `createrepo_c`, because signing changes the files.
Why does apt report a hash mismatch right after I publish?
Almost always a caching layer serving a mix of old and new metadata. Repository metadata is internally consistent, so a partial update is a broken repository. Purge every file the publish rewrote, or give metadata a short TTL.
Can one repository serve amd64 and arm64?
Yes. For APT, generate a `Packages` file per architecture under the same suite and list both in `Architectures`. For YUM, put both packages in the same directory — `createrepo_c` records the architecture for each one.
Should the repository files live in git?
Usually not. Package pools grow with every release and git keeps every version forever. Build the tree from your release artifacts and sync it to the origin, keeping only the scripts and the public key in git. Make the build reproducible so the repository can be recreated from scratch at any time.