When intranet servers can’t reach public YUM repositories, patching and installing software are left without a fallback. The fix: collect the RPM packages onto one server, generate repository metadata with createrepo, and publish it over HTTP with OpenResty — clients need nothing more than a repo file pointing at that server, and dnf install behaves exactly as it would against a public mirror. This post walks the entire flow end to end on a Rocky Linux 9.3 container, with CentOS 7 era differences listed separately.

The Overall Approach

The plan has three steps:

  1. Collect the RPM packages into a directory on the server
  2. Create the repository with the createrepo_c command
  3. Configure OpenResty to publish the YUM repository

Two pieces of software need to be prepared:

Software Purpose Version and source
createrepo_c Generating repository metadata Bundled with the Rocky 9 AppStream, one dnf install away
OpenResty Publishing the repository over HTTP 1.31.1.1, installed from the official yum repository

The official download page for OpenResty is here; for the source-build install steps, refer to this article. This post installs straight from the official yum repository — far less hassle than compiling.

The publishing layer uses HTTP rather than FTP or a directly shared directory, for three reasons: dnf supports http baseurl natively, so clients need zero configuration; HTTP is one-way and stateless, so the firewall only needs one port opened; and OpenResty can pitch in elsewhere at the same time — hosting an internal static site on the same nginx costs nothing. The repository metadata itself is only a few megabytes, and even with hundreds of clients pulling from it, the cache hit rate stays comfortably high.

Deployment

Prepare the RPM Packages

The RPM packages come from two sources:

  1. Packages shipped inside the system installation ISO image — mount it and copy out the Packages directory
  2. Sync the public mirrors to local storage

For scattered one-off packages, you can also pull them directly on an internet-connected machine with dnf download --resolve <package name> — --resolve downloads the dependencies along with the package. Do not skip this flag: in testing, a bare dnf download jq without --resolve left the client install failing outright with nothing provides libonig.so.5()(64bit) after the package went into the repository — the repo contained jq itself, but its dependency libonig never made it through the door. This is the single most common pitfall of a self-hosted repository: always collect the dependencies together with the packages.

Put the collected packages into the repository directory in one place (this post uses /data/yum-repo/). When copying packages out of an ISO, remember to mount it first with something like mount -o loop CentOS-7-x86_64-DVD.iso /mnt/iso; copying the whole Packages directory is enough. For directory layout, split by OS version (for example, /data/yum-repo/9/ and /data/yum-repo/7/ each keeping their own repodata) — mixing them together produces the strange outcome of an el9 client resolving el7 package metadata.

Install createrepo

On Rocky 9, createrepo has been succeeded by createrepo_c, a C rewrite — install it straight from the AppStream repository:

1
dnf install createrepo_c

After the install, both createrepo and createrepo_c exist on the system; the former is a compatibility symlink pointing at the latter, and the usage is identical. The el7-era rpm dependency chain of deltarpm, python-deltarpm, and libxml2-python is no longer needed on el9 — one package covers it all.

Create the Repository

1
createrepo_c -d /data/yum-repo/

This command scans all the RPM packages under /data/yum-repo/ and generates repodata/ metadata in the directory — clients rely on this index to look up packages. -d means a sqlite-format index is generated as well (on el9 it is generated by default anyway).

Figure 1

The generated repodata/ contains 7 files: the primary, filelists, and other information each come as one xml.gz and one sqlite.bz2, plus the top-level entry point repomd.xml. Unlike the CentOS 7 era, where *-primary.xml.gz let you recognize package names at a glance, el9 metadata files switched to hash-based names — you can only identify a file through the references inside repomd.xml, though client parsing is unaffected.

Install and Configure OpenResty

Add the official OpenResty yum repository and install:

1
2
3
4
5
6
7
8
# /etc/yum.repos.d/openresty.repo
[openresty]
name=Official OpenResty Open Source Repository
baseurl=https://openresty.org/package/rocky/$releasever/$basearch
repo_gpgcheck=0
gpgcheck=0
enabled=1
gpgkey=https://openresty.org/package/pubkey.gpg
1
dnf install openresty

Note: the official config template ships with repo_gpgcheck=1; in testing inside a container it failed with repomd.xml GPG signature verification error: Bad GPG signature, and everything worked after changing it to 0. In production, if you want to keep metadata signature verification, first make sure the system time is accurate and the GPG public key has been imported.

Once OpenResty is installed, its config file lives at /usr/local/openresty/nginx/conf/nginx.conf. Add a location block inside the server block that maps the site root to the repository directory:

1
2
3
4
location / {
root /data/yum-repo;
autoindex on;
}

autoindex on makes the directories browsable, which makes it easy to manually check whether a package is present. Start the service and verify:

1
2
/usr/local/openresty/bin/openresty
curl -sI http://127.0.0.1:8080/repodata/repomd.xml

Figure 2

An HTTP 200 with the repomd.xml content coming back means the publishing layer works. Clients can now download rpm files and repodata by path.

Client Configuration

Write a custom repo file:

1
2
3
4
5
6
# /etc/yum.repos.d/local.repo
[local-yum]
name=Local YUM Repo
baseurl=http://IP:PORT
gpgcheck=0
enabled=1

Here IP:PORT is the address of the YUM server and the port it serves. If the server side splits directories by OS version, you can build the baseurl path with dnf’s built-in variables, e.g. baseurl=http://IP:PORT/$releasever/$basearch — each client resolves its own version and architecture, so one repo file works across the whole fleet. Then rebuild the cache on the client and do a real install — keep every other repository disabled throughout and only local-yum enabled; if the package installs, the pipeline is truly working:

1
2
3
dnf clean all
dnf --disablerepo="*" --enablerepo="local-yum" makecache
dnf --disablerepo="*" --enablerepo="local-yum" install tree

Figure 3

Once Installed and Complete! appear, the entire chain — metadata parsing, dependency resolution, package download — has closed the loop on the self-hosted repository.

Day-to-Day Maintenance

Updating the Self-Hosted Repository

After adding new packages to the directory, there is no need for a full rebuild — an incremental update is enough:

1
createrepo_c --update /data/yum-repo/

--update merges only the new or changed packages into the existing index, which is far faster than a full rebuild on a large repository. After updating, run dnf clean all && dnf makecache once on the client and the new packages become visible. Cache expiry is also something the client handles on its own — repomd.xml carries a metadata_expire (the Rocky official repositories default to 6 hours), and when a self-hosted repository doesn’t set this field, dnf refreshes according to its default policy. For packages you need urgently, a manual clean followed by makecache is the safest bet.

Troubleshooting Download Failures

During a client install, this error appears:

1
2
3
Error: Error downloading packages:
wget-1.21.1-11.el9_8.aarch64: Cannot download, all mirrors were already
tried without success

It means the package file recorded in the repodata index no longer exists on the repository — the index wasn’t refreshed after the package was moved or deleted, so the client goes looking with a stale index, hitting 404s until it gives up. The yum-era equivalent of this failure was [Errno 2] Local file does not exist; dnf simply words it differently — same disease.

The fix, verified in practice: restore the accidentally removed package to the directory (or confirm the package really should be retired), then delete repodata/ and rebuild with createrepo_c -d, or run createrepo_c --update for an incremental merge — finally, clear the client cache and retry.

For this class of problem there is a three-step self-check: first, on the server itself, curl -sI http://127.0.0.1:8080/repodata/repomd.xml to confirm the publishing layer returns 200; next, on the client, dnf repolist to confirm the repository is visible; finally, dnf makecache to see whether the metadata fetch reports errors. The three layers correspond to publishing, addressing, and indexing — whichever breaks first is where the problem lives.

Figure 4

Differences from Older Versions (CentOS 7)

Readers migrating over from CentOS 7 should pay attention to these four places:

  • Package name: createrepo (Python implementation) is replaced by createrepo_c; the el7 deltarpm dependency chain no longer needs manual rpm -ivh;
  • Command: the client-side yum becomes dnf; yum makecache corresponds to dnf makecache, and on el9 yum is merely an alias for dnf;
  • Metadata naming: repodata moved from readable file names to hash-based names; troubleshoot through repomd.xml;
  • Collecting packages: yumdownloader --resolve becomes dnf download --resolve; the missing-dependency pitfall is identical on both system generations.
  • Installing OpenResty: in the el7 days it was mostly a source build; nowadays the official project provides a yum repository for Rocky/RHEL 9 directly — one command, dnf install openresty, and upgrades and maintenance are painless.

Notes and Cautions

  • Dependencies must be complete: with only the main package in the repository and no dependencies, client installs fail with nothing provides; always collect with --resolve, and sync in the entire base repository of the matching version when necessary.
  • The repository directory and repodata must stay in sync: any package added or removed calls for createrepo_c --update, followed by dnf clean all and dnf makecache on the client — otherwise you get the Cannot download error above.
  • The limits of gpgcheck=0: skipping signature verification is fine for internal use, but never expose this kind of repository to the public internet; for security-sensitive scenarios, sign your self-hosted repository properly (rpmsign + import the public key).
  • The ISO only covers system-shipped packages: packages from third-party repositories like EPEL must be collected separately before going into the repository — don’t expect one ISO to cover everything.
  • Firewall pass-through: remember to open the publishing port on the server (e.g. 8080) with firewall-cmd --add-port=8080/tcp --permanent && firewall-cmd --reload; otherwise clients can’t connect and you’ll end up blaming a perfectly healthy repository.

A LAN YUM repository comes down to two server-side packages and one client-side repo file — the cost is nearly zero. What actually decides whether it works well is whether the dependencies were collected along with the packages and whether the index was updated after packages changed. Get those two things right, and dnf install on intranet machines feels as smooth as it does against the public internet. And if the day comes to scale out to multiple data centers, make this OpenResty instance the upstream and cache a copy in each data center behind a reverse proxy — the architecture has room to keep growing.

This article was rebuilt from the author’s CSDN blog posts published between 2020 and 2024, originally published on CSDN.