Skip to content
Published on

Building a Local Repository Inside an Air-Gapped Network — createrepo_c, repodata, .repo Files, and GPG Keys

Share
Authors

Opening — a directory full of rpms is not a repository yet

What post 2 produced was a directory containing rpm files. Carry it into the air-gapped network in that state, run dnf install, and dependencies still will not resolve.

The reason is that dnf does not open rpm files one by one. What dnf reads is repository metadata, and you have to generate that separately. The tool that generates it is createrepo_c.

This post builds that metadata, registers it as a repository, makes signature verification work, and finally sorts out the cache problem that catches more people than anything else in an air-gapped network.

Building metadata with createrepo_c

Here is the most basic form.

# Install the repository tool (download it on a connected machine in advance and carry it in too)
sudo dnf install createrepo_c

# Turn the directory into a repository
sudo createrepo_c /srv/repo/rhel9-baseos

When the command finishes, a repodata directory appears inside the target directory. Look inside and the structure shows itself.

ls -1 /srv/repo/rhel9-baseos/repodata/
# repomd.xml
# <checksum>-primary.xml.gz
# <checksum>-filelists.xml.gz
# <checksum>-other.xml.gz

repomd.xml is the index and the rest is the actual data. primary holds package names, versions, and dependency information; filelists holds the list of files each package contains; other holds the changelog. The reason a checksum is prefixed to the filenames is in the man page: --unique-md-filenames is "Include file's checksum in metadata filename for HTTP caching (default)", and it is the default. Because the filename changes when the content changes, it prevents an intermediate proxy from handing back a stale copy.

These are the options you actually reach for in practice.

# After adding packages, refresh incrementally instead of regenerating everything
sudo createrepo_c --update /srv/repo/rhel9-baseos

# Add workers to cut generation time on a large repository
sudo createrepo_c --workers 8 /srv/repo/rhel9-baseos

# Include package group (comps) information as well
sudo createrepo_c --groupfile /srv/repo/comps.xml /srv/repo/rhel9-baseos

Per the man page, each option is defined like this.

OptionMan page description
--update"Reuse existing metadata for unchanged rpms based on file size and mtime."
--workers"Number of workers to spawn for reading rpms."
-g, --groupfile"Path to groupfile to include in metadata."
-s, --checksum"Choose the checksum type used in repomd.xml and for packages in the metadata. The default is now sha256."
--compress-type"Compression type (bz2, gz, zck, zstd, xz)."
-i, --pkglist"Text file containing complete list of packages to include."
--retain-old-md NUM"Keep old repodata (0 removes all, positive numbers specify copies to retain)."
-x, --excludes"Path patterns to exclude, can be specified multiple times."
-o, --outputdir"Optional output directory."

-d, --database is marked in the man page as "DEPRECATED: Generate sqlite databases for use with yum". If you see that option in an old procedure document, you can delete it.

--pkglist is especially useful in an air-gapped network. Feed it the transfer manifest you will build in post 4 and exactly what the manifest lists is what goes into the repository — nothing else. It is a structural way to prevent the list and the actual repository from drifting apart.

Registering it with a .repo file

Metadata is useless if dnf does not know where it is. The RHEL 9 documentation is explicit. Repositories are defined in /etc/dnf/dnf.conf or in a .repo file under /etc/yum.repos.d/, and it recommends: "Define your repositories in the .repo file instead of /etc/dnf/dnf.conf".

Here is the case where a single server uses a local directory directly.

# /etc/yum.repos.d/airgap-local.repo
[airgap-baseos]
name=RHEL 9 BaseOS (airgap local, snapshot 2026-08-15)
baseurl=file:///srv/repo/rhel9-baseos
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release
metadata_expire=-1

[airgap-appstream]
name=RHEL 9 AppStream (airgap local, snapshot 2026-08-15)
baseurl=file:///srv/repo/rhel9-appstream
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release
metadata_expire=-1

If you are serving several servers, stand up one internal HTTP server and change only the baseurl.

# /etc/yum.repos.d/airgap-internal.repo
[airgap-baseos]
name=RHEL 9 BaseOS (internal mirror, snapshot 2026-08-15)
baseurl=http://repo.internal.example.com/rhel9/baseos
enabled=1
gpgcheck=1
gpgkey=http://repo.internal.example.com/keys/RPM-GPG-KEY-redhat-release
metadata_expire=-1
priority=1

Stamping the snapshot date into the name is not a stylistic preference. Six months from now, the fastest way to find out which point-in-time content this server was installed from is the output of dnf repolist -v.

Per the dnf configuration documentation, each entry means the following. baseurl is "List of URLs for the repository", enabled is "Include this repository as a package source. The default is True", gpgcheck is "Whether to perform GPG signature check on packages found in this repository. The default is False", gpgkey is "URLs of a GPG key files that can be used for signing metadata and packages of this repository", and priority is "The priority value of this repository, default is 99".

You have to remember that the default for gpgcheck is false. If you do not write it down, nothing gets checked. That is particularly dangerous in an air-gapped network, for reasons post 4 covers.

You can also add a repository by command, and this is where the version difference shows up.

# RHEL 9 / RHEL 10
sudo dnf config-manager --add-repo http://repo.internal.example.com/rhel9/baseos

# RHEL 8 (as written in the official documentation)
sudo yum-config-manager --add-repo http://repo.internal.example.com/rhel8/baseos

The RHEL 8 official documentation writes it as yum-config-manager --add-repo, while the RHEL 9 and RHEL 10 documentation writes dnf config-manager --add-repo. Both add the note that "repositories added by this command are enabled by default".

Carrying in a GPG key and registering it

With gpgcheck=1 turned on, the first install asks for a key. An air-gapped network has no path to fetch keys automatically, so the key file is part of what you carry in.

The exact path and name of the key file differ by distribution and version, so this post does not assert a specific filename. Check on the connected machine first, then put that file in the bundle. Replace the filename in the example below with the real name you verified.

# First find out which GPG key files this system has
ls -1 /etc/pki/rpm-gpg/

# Pin down exactly which key file the release package installed
rpm -ql redhat-release | grep -i gpg

# Check which key the current repository configuration references
grep -h '^gpgkey=' /etc/yum.repos.d/*.repo | sort -u

# Carry in the key you verified and place it in the standard location
sudo cp ./keys/RPM-GPG-KEY-redhat-release /etc/pki/rpm-gpg/

# Register the key in the rpm keyring
sudo rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release

# Check the registered keys
rpm -q gpg-pubkey --qf '%{NAME}-%{VERSION}-%{RELEASE} %{SUMMARY}\n'

There is one version-related caveat here. The man page of recent upstream RPM classifies -K, --checksig, and --import as "Obsolete compatibility aliases" and points you to rpmkeys(8). On RHEL 8, 9, and 10 rpm --import still works as-is, but with the future in mind the rpmkeys spelling is the safer one.

# The spelling recommended going forward
sudo rpmkeys --import /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release

# List registered keys by fingerprint and user ID
rpmkeys --list

The rpmkeys man page defines -i, --import as "Import ASCII-armored public keys. Digital signatures cannot be verified without the corresponding public key (aka certificate)" and -l, --list as "List currently imported public key(s) (aka certificates) by their fingerprint and user ID".

It is worth noting that package signatures and metadata signatures are different settings. gpgcheck is a check on packages, while repo_gpgcheck is "Whether to perform GPG signature check on this repository's metadata", and its default is false as well. Signing the metadata of a repository you built yourself takes a separate procedure, and if you did not sign it, you must not turn repo_gpgcheck on.

The cache swallows your update

This is the most frequent report you get in an air-gapped network: a package was added to the repository, but the server does not see it.

The cause is usually the metadata cache. The dnf configuration documentation defines metadata_expire as "The period after which the remote repository is checked for metadata update and in the positive case the local metadata cache is updated. The default corresponds to 48 hours". In other words, out of the box, old metadata gets used for up to two days.

If you updated the repository, handle it like this.

# 1. Repository side: regenerate the metadata (this step really does get skipped a lot)
sudo createrepo_c --update /srv/repo/rhel9-baseos

# 2. Client side: only mark the cache expired (the lightest option)
sudo dnf clean expire-cache

# 3. If that does not do it, delete the metadata cache
sudo dnf clean metadata

# 4. Last resort — delete everything
sudo dnf clean all

# 5. Refill the cache ahead of time
sudo dnf makecache

The dnf documentation defines each one as follows: expire-cache "Marks the repository metadata expired", metadata "Removes repository metadata", packages "Removes any cached packages from the system", and all "Does all of the above". Work down the list in order. dnf clean all is especially costly in an air-gapped network, because there is no internet to refill the cache you just deleted. That said, a transferred repository is local, so the cost of regenerating it is low.

This is why the earlier .repo examples used metadata_expire=-1. A transferred repository never changes until a human explicitly updates it, so re-checking on expiry is meaningless. Having a human signal the update point with dnf clean expire-cache is the more predictable arrangement.

Checking that the repository actually works

Once registration is done, verify three things.

# 1. Is the repository recognised, and is its package count non-zero
dnf repolist -v

# 2. Do all dependencies resolve using this repository alone
dnf repoclosure --repo=airgap-baseos --repo=airgap-appstream

# 3. Does an actual install work (check the transaction only, then stop)
sudo dnf install --assumeno httpd

Number 2 is the repoclosure introduced in post 2. Run it once before the transfer and once more afterwards against the real repository configuration. Files really do go missing during the transfer.

Redistributing Red Hat content without a subscription may breach your agreement, so check your organisation's licence terms first. An internal HTTP mirror in particular has the character of "redistribution", so it is safer to check the contract terms before you build it.

Closing — a repository is a contract, not a pile of files

createrepo_c itself is a one-line command. The hard part comes after it.

Did you regenerate the metadata? Did you carry in the key and register it? Did you state gpgcheck explicitly? Did you expire the cache? Those four account for most air-gapped repository failures. Each of the four has a one-line verification command, so put them straight into the procedure document.

Commands and options were verified against the official documentation on 2026-08-15. Behaviour differs by RHEL version, so re-check against the documentation for the version you are running.

Try it yourself

  • Linux Terminal — build out repository directory structures and get a feel for paths
  • chmod Calculator — design the permissions of a repository directory served over HTTP
  • Hash Generator — see the checksum concept behind repomd.xml for yourself

Previous / next in the series

References