🚧 Air-Gapped Repositories Guide

Building, signing, carrying, serving and updating offline mirrors for apt and dnf, with the sizes you really need, the tamper tests, and the traps found on the way, all run on machines with no internet

Video: Air-Gapped Package Repositories for Ubuntu and Red Hat Systems (sysadmin video)
Prefer to watch? This video runs the guide's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube. It is part of the Services, Storage and Security playlist.
Air-gapped package repositories for Ubuntu and Red Hat systems
aptly publish snapshot -distribution=noble all-1 lab        dnf reposync --download-metadata -p /srv/mirror

An "air gap" means a network with no way out to the internet: a production zone, a lab, a secure site. Its servers still need software and security updates, and apt and dnf want to fetch them from a repository. The answer is a mirror: a copy of the repositories you need, built on a machine that CAN reach the internet, carried across the gap as a file, and served inside by an ordinary web server. This page builds that for Ubuntu (apt) and for the Red Hat family (dnf), tests the result on machines that really have no internet, and shows what goes wrong: a mirror that is too big, a filter that silently matches nothing, a key that is trusted too easily, a repository that never admits to being stale, a package cache that says "no match" for a package that is there.
Every output below ran for real, on 9 October 2026, in Docker containers. The "online" machines had internet. The "offline" ones sat on a Docker network created with --internal, which has no route out: they cannot resolve or reach any outside address, and I proved that before using them. Ubuntu side: Ubuntu 24.04 (apt 2.8.3), aptly 1.5.0, GnuPG 2.4.4. Red Hat side: Rocky Linux, a free rebuild of Red Hat Enterprise Linux, because real RHEL content needs a Red Hat subscription which I do not have for this lab. The Rocky container image was 9.3 (dnf 4.14.0, rpm 4.16.1, createrepo_c 0.20.1), but the repositories it fetched were the current ones (9.8). What I could NOT test is anything that needs Red Hat's own servers or products (subscription-manager, Satellite, the content delivery network); for those the page says what the documentation I could read says, and nothing more. Where something was not run, this page says "not run in the lab".

The idea: what has to cross the gap

Four things have to get from the online side to the offline side, and only one of them is "packages":
  1. the packages (.deb or .rpm files)
  2. the repository metadata: the index that says which packages exist, with sizes and hashes
  3. the signatures: on the metadata (and, for RPM, on the packages)
  4. the key that verifies the signatures, which must arrive by a channel you trust
The offline side then needs a web server (any static server will do: it is only files), and the clients need a configuration that points at it and, for the sake of safety, at the right key. In between sits the "carry": a removable disk, a one-way transfer service, a reviewed file drop. In this lab the carry is docker cp through the host, and each transfer is checked with sha256sum, which is what the real thing should do too. Everything on the carried medium is a plain file; there is no network protocol to open.
The lab, in one picture:
  online   (internet)      builds the mirror, signs the metadata
     |  one tar.gz + one checksum + the key file
  [ the carry: a disk, a file drop, docker cp ]
     |
  repo     (no internet)   unpacks it and serves it over HTTP
  client   (no internet)   apt or dnf, pointed at repo only
The offline network, proved offline from the repo server:
$ curl -sS -m 6 -o /dev/null http://archive.ubuntu.com/ubuntu/dists/noble/Release; echo "curl exit=$?"
curl: (6) Could not resolve host: archive.ubuntu.com
curl exit=6
And a client with its default Ubuntu sources, before anything is configured:
$ apt-get update 2>&1 | grep '^W:'
W: Failed to fetch http://archive.ubuntu.com/ubuntu/dists/noble/InRelease  Temporary failure resolving 'archive.ubuntu.com'
W: Failed to fetch http://archive.ubuntu.com/ubuntu/dists/noble-updates/InRelease  Temporary failure resolving 'archive.ubuntu.com'
W: Failed to fetch http://archive.ubuntu.com/ubuntu/dists/noble-backports/InRelease  Temporary failure resolving 'archive.ubuntu.com'
W: Failed to fetch http://security.ubuntu.com/ubuntu/dists/noble-security/InRelease  Temporary failure resolving 'security.ubuntu.com'
W: Some index files failed to download. They have been ignored, or old ones used instead.
That is the starting point of every air-gapped machine.

How big is a full mirror?

The first question for any plan, and it can be answered without downloading a single package, from the repositories' own indexes. For Ubuntu 24.04 (noble) I read the package index of every component of every pocket (release, updates, security, backports) for the amd64 architecture and added up the size of the .deb files, counting a file once even if several pockets list it:
$ python3 ubsize.py
pocket           component   packages  size of the .debs  index (xz)
noble            main            6099            8.86 GB      1.4 MB
noble            restricted       492            3.62 GB      0.1 MB
noble            universe       64755          137.24 GB     15.0 MB
noble            multiverse       1154            8.40 GB      0.3 MB
noble-updates    main            6864           82.59 GB      1.4 MB
noble-updates    restricted      9868          172.93 GB      1.8 MB
noble-updates    universe        8205           45.07 GB      1.7 MB
noble-updates    multiverse       274            2.39 GB      0.1 MB
noble-security   main            5715           79.69 GB      1.1 MB
noble-security   restricted      9395          161.77 GB      1.7 MB
noble-security   universe        5902           35.50 GB      1.2 MB
noble-security   multiverse       254            1.87 GB      0.0 MB
noble-backports  main             175            0.48 GB      0.0 MB
noble-backports  restricted         0            0.00 GB      0.0 MB
noble-backports  universe         115            0.20 GB      0.0 MB
noble-backports  multiverse         1            0.00 GB      0.0 MB

unique files across all four pockets (a pool file is stored once):
  main         13484 files    95.83 GB
  restricted   11592 files   199.68 GB
  universe     73484 files   185.12 GB
  multiverse    1429 files    10.80 GB
  total        99989 files   491.42 GB   (457.7 GiB)
  main + restricted only: 295.51 GB (275.2 GiB)
About 491 GB for amd64 binaries alone (source packages and other architectures are extra), on the day I looked. The surprise is "restricted": 200 GB, almost all of it in the updates and security pockets. I looked inside:
$ python3 ubbig.py
largest packages in noble-updates/restricted:
     173 MB  libnvidia-gl-580 580.178.04-0ubuntu0.24.04.1
     173 MB  libnvidia-gl-580-server 580.178.04-0ubuntu0.24.04.1
     151 MB  libnvidia-gl-470 470.256.02-0ubuntu0.24.04.6
     106 MB  linux-objects-nvidia-610-7.0.0-1008-azure 7.0.0-1008.8~24.04.2+2
     106 MB  linux-objects-nvidia-610-7.0.0-1014-azure 7.0.0-1014.14~24.04.1+1
     106 MB  linux-objects-nvidia-610-7.0.0-1013-azure-fde 7.0.0-1013.13~24.04.1+1
by first word of the package name:
  linux        169.0 GB
  nvidia         3.2 GB
  libnvidia      0.7 GB
  xserver        0.0 GB
packages: 9868   distinct names: 9818
packages named linux-*nvidia*: 169.0 GB of 172.9 GB in this index
169 of the 173 GB in that one index are NVIDIA kernel-module packages, one set for every kernel flavour and version. If you do not run NVIDIA drivers on those kernels, you are carrying them for nothing. The lesson: a "full mirror" is rarely what you want. Choose components (main alone is 96 GB across all pockets, main plus universe about 281 GB), choose the pockets, and filter.
For the Red Hat family I asked dnf for the same numbers from the repository metadata (dnf repoquery, without downloading packages). Rocky 9.8, x86_64:
$ dnf repoquery --disablerepo='*' --enablerepo=REPO --available --queryformat '%{downloadsize}\n'
== baseos
  all versions in the repo:   6038 packages, 17.92 GB (16.7 GiB)
  newest version only:        2370 packages, 1.74 GB (1.6 GiB)
== appstream
  all versions in the repo:   17418 packages, 26.57 GB (24.7 GiB)
  newest version only:        11654 packages, 11.06 GB (10.3 GiB)
== extras
  all versions in the repo:   108 packages, 3.6 MB
== crb
  all versions in the repo:   5760 packages, 38.78 GB (36.1 GiB)
  newest version only:        4182 packages, 14.83 GB (13.8 GiB)
(--latest-limit 1 gave the "newest" lines.) Here the choice between "everything the repository holds" and "only the newest version of each package" is worth a factor of 10 for BaseOS and 2.4 for AppStream. Rocky keeps old versions in its repositories; whether Red Hat's repositories do the same, and how big theirs are, I did not measure.

Ubuntu: build a mirror with aptly

Several tools can build a Debian-style mirror: aptly, apt-mirror, debmirror and reprepro are all in Ubuntu 24.04's own repositories (apt-cache policy shows aptly 1.5.0, debmirror 2.39, apt-mirror 0.5.4, reprepro 5.3.1). I used aptly, because it has the two features an air gap needs: snapshots (a frozen, named state of the mirror that you can publish, compare and roll back to) and built-in signing. The others were not run in the lab.
Work on the online machine. First, give aptly the key it uses to verify what Ubuntu publishes. Without this it will refuse the mirror:
$ gpg --no-default-keyring --keyring trustedkeys.gpg --import /usr/share/keyrings/ubuntu-archive-keyring.gpg
gpg: Total number processed: 3
gpg:               imported: 3
Now one mirror per pocket. The filter keeps only the packages you ask for AND (with -filter-with-deps) everything they depend on, from that pocket. -architectures is a global option that goes BEFORE the word mirror:
$ aptly -architectures=amd64 mirror create -filter='curl | tree' -filter-with-deps noble-rel http://archive.ubuntu.com/ubuntu noble main
gpgv: Signature made Thu Apr 25 15:11:21 2024 UTC
gpgv:                using RSA key F6ECB3762474EDA9D21B7022871920D1991BC93C
gpgv: Good signature from "Ubuntu Archive Automatic Signing Key (2018) <ftpmaster@ubuntu.com>"

Mirror [noble-rel]: http://archive.ubuntu.com/ubuntu/ noble successfully added.
You can run 'aptly mirror update noble-rel' to download repository contents.
$ aptly -architectures=amd64 mirror create -filter='curl | tree' -filter-with-deps noble-upd http://archive.ubuntu.com/ubuntu noble-updates main
$ aptly -architectures=amd64 mirror create -filter='curl | tree' -filter-with-deps noble-sec http://security.ubuntu.com/ubuntu noble-security main
aptly checked Ubuntu's own signature on the Release file before accepting the mirror ("Good signature from Ubuntu Archive Automatic Signing Key"). That is the half of the chain you do not control: if the upstream signature cannot be verified, stop.
Download the contents:
$ aptly mirror update noble-sec
Packages filtered: 5715 -> 13.
Building download queue...
Download queue: 4 items (0.58 MiB)
...
Mirror `noble-sec` has been successfully updated.
Two lines in that output are worth reading: "Packages filtered: 5715 -> 13" (of 5,715 packages in the security pocket, 13 survived the filter) and "Download queue: 4 items (0.58 MiB)". The three mirrors then held 32, 26 and 13 packages, 22 MB in total. And here is the first mistake I made, which aptly did NOT warn about. I filtered for 'curl | tree', but tree is in the universe component and I mirrored only main. The filter simply matched nothing for tree, no message, and the client said later:
$ apt-get install -y tree
E: Unable to locate package tree
Check which component a package lives in before you decide what to mirror:
$ apt-cache show PACKAGE | awk '/^Filename:/{print $2}'          (the second path element is the component)
curl   main
tree   universe
jq     main
less   main
lsof   main
nano   main
htop   main
Next, snapshots: freeze each mirror, then merge the three into one repository in which the NEWEST version of each package wins (-latest). That is how release, updates and security become one consistent repository:
$ aptly snapshot create rel-1 from mirror noble-rel
$ aptly snapshot create upd-1 from mirror noble-upd
$ aptly snapshot create sec-1 from mirror noble-sec
$ aptly snapshot merge -latest all-1 rel-1 upd-1 sec-1
$ aptly snapshot show -with-packages all-1 | grep -E 'Number of packages|^  (curl|libc6|libssl3t64|libkrb5-3)_'
Number of packages: 32
  curl_8.5.0-2ubuntu10.15_amd64
  libc6_2.39-0ubuntu8.9_amd64
  libkrb5-3_1.20.1-6ubuntu2.10_amd64
  libssl3t64_3.0.13-0ubuntu3.16_amd64
The repository needs a signing key of your own, because your mirror is a new repository with a new Release file, and the original Ubuntu signature cannot cover it. Make one (no passphrase here because it is a lab; in real life protect it, and keep it on the online side only):
$ gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key 'Lab Repo (air-gap demo) <repo@lab.example>' ed25519 sign never
fingerprint: 4BC563A2A050666CB116017FBDB5BEC64D9C999D
Publish the merged snapshot as distribution noble under the prefix lab, signed with that key:
$ aptly publish snapshot -distribution=noble -architectures=amd64 -batch -passphrase='' -gpg-key=4BC563A2A050666CB116017FBDB5BEC64D9C999D all-1 lab
Snapshot all-1 has been successfully published.
Please setup your webserver to serve directory '/root/.aptly/public' with autoindexing.
Now you can add following line to apt sources:
  deb http://your-server/lab/ noble main
Don't forget to add your GPG key to apt with apt-key.
Notice the last line: aptly's closing message still recommends apt-key, which is deprecated. Do not follow it; the key goes into a file referenced by Signed-By (below). The published tree is plain files:
$ find /root/.aptly/public -maxdepth 4 | sort | head -10
/root/.aptly/public
/root/.aptly/public/lab
/root/.aptly/public/lab-repo.gpg
/root/.aptly/public/lab/dists
/root/.aptly/public/lab/dists/noble
/root/.aptly/public/lab/dists/noble/Contents-amd64.gz
/root/.aptly/public/lab/dists/noble/InRelease
/root/.aptly/public/lab/dists/noble/Release
/root/.aptly/public/lab/dists/noble/Release.gpg
/root/.aptly/public/lab/dists/noble/main
$ sed -n 1,8p /root/.aptly/public/lab/dists/noble/Release
Origin: lab noble
Label: lab noble
Suite: noble
Codename: noble
Date: Fri, 9 Oct 2026 14:38:57 UTC
Architectures: amd64
Components: main
Description: Generated by aptly
$ du -sh /root/.aptly/public
12M	/root/.aptly/public
InRelease is the Release file with the signature wrapped around it (what apt reads first), Release.gpg is the detached signature of Release, the pool holds the .deb files. The key that verifies them is exported from the online side:
$ gpg --export 4BC563A2A050666CB116017FBDB5BEC64D9C999D > /root/.aptly/public/lab-repo.gpg

Ubuntu: carry it across, serve it, use it

Pack the published tree and write its checksum. One lesson from the lab: write the checksum file with a RELATIVE name, from inside the directory. I made mine from another directory, so it contained the absolute path /root/lab-mirror.tar.gz, and it verified on the machine that happened to have that path and failed ("Permission denied", "FAILED open or read") on the host that carries the disk.
$ tar -C /root/.aptly -czf /root/lab-mirror.tar.gz public
$ sha256sum lab-mirror.tar.gz
5c3f22bcbb5354cdf4a713c2175606ff383472035b23949a5929c076b86cf020  lab-mirror.tar.gz
$ ls -la lab-mirror.tar.gz
-rw-r--r-- 1 root root 11248621 Oct  9 14:39 lab-mirror.tar.gz
Carry it (here: docker cp out of the online machine, onto the "disk", into the offline one), check it there, unpack, and serve:
$ sha256sum -c lab-mirror.sha256
lab-mirror.tar.gz: OK
$ mkdir -p /srv/mirror && tar -C /srv/mirror --strip-components=1 -xzf lab-mirror.tar.gz
$ ls /srv/mirror
lab
lab-repo.gpg
$ python3 -m http.server 8000 --directory /srv/mirror
$ curl -sI http://localhost:8000/lab/dists/noble/InRelease | head -2
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.12.3
Python's built-in server is enough to prove the point and is NOT what you would run: use nginx or Apache, and think about HTTPS and access control. (The metadata is signed, so a client detects tampering with it; it is not encrypted.)
The key must reach the client by a trustworthy path, and be checked. The client in this lab has neither curl nor gpg, which is realistic, so the key file travelled on the same disk, and its sha256 had been written down on the online side:
$ echo '84939cad5b1a064f001c947fd99a6d19c2a4ad86ff0f54eded01f1788e524c18  /root/lab-repo.gpg' | sha256sum -c -
/root/lab-repo.gpg: OK
$ install -m 644 /root/lab-repo.gpg /etc/apt/keyrings/lab-repo.gpg
(the /etc/apt/keyrings directory is the place Debian and Ubuntu now recommend for keys that you manage locally; /etc/apt/trusted.gpg.d and apt-key are the deprecated way, according to the Debian wiki page on third-party repositories.) Then the default Ubuntu sources are moved out of the way, and the mirror is described in the deb822 format, with Signed-By tying THIS key to THIS repository only:
$ mv /etc/apt/sources.list.d/ubuntu.sources /root/ubuntu.sources.original
$ cat /etc/apt/sources.list.d/lab.sources
Types: deb
URIs: http://repo:8000/lab
Suites: noble
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/lab-repo.gpg
$ apt-get update
Get:1 http://repo:8000/lab noble InRelease [3700 B]
Get:2 http://repo:8000/lab noble/main amd64 Packages [11.4 kB]
Fetched 15.1 kB in 0s (140 kB/s)
Reading package lists...
Now a package that is on the mirror and not on the client:
$ apt-get install -y curl 2>&1 | grep -E '^(Get:|Setting up curl|[0-9]+ upgraded|Need to get)'
0 upgraded, 15 newly installed, 0 to remove and 1 not upgraded.
Need to get 2168 kB of archives.
Get:1 http://repo:8000/lab noble/main amd64 libkrb5support0 amd64 1.20.1-6ubuntu2.10 [34.9 kB]
...
Get:15 http://repo:8000/lab noble/main amd64 curl amd64 8.5.0-2ubuntu10.15 [227 kB]
Setting up curl (8.5.0-2ubuntu10.15) ...
$ apt-cache policy curl | head -6
curl:
  Installed: 8.5.0-2ubuntu10.15
  Candidate: 8.5.0-2ubuntu10.15
  Version table:
 *** 8.5.0-2ubuntu10.15 500
        500 http://repo:8000/lab noble/main amd64 Packages
curl and its fourteen libraries came from the mirror, with the dependency resolution done by apt as usual. (aptly's -filter-with-deps is what made all of them available.)

Ubuntu: what refuses to work, and why

Each case below used its own empty apt list directory (apt-get -o Dir::State::Lists=/tmp/lists-N update), so that a cached success could not hide the result.
A. No key. The Signed-By line removed, and the key in no place apt looks:
$ apt-get update
Get:1 http://repo:8000/lab noble InRelease [3700 B]
Err:1 http://repo:8000/lab noble InRelease
  The following signatures couldn't be verified because the public key is not available: NO_PUBKEY BDB5BEC64D9C999D
W: GPG error: http://repo:8000/lab noble InRelease: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY BDB5BEC64D9C999D
E: The repository 'http://repo:8000/lab noble InRelease' is not signed.
exit=100
B. The wrong key (Signed-By pointing at Ubuntu's own archive keyring): the same NO_PUBKEY error. apt does not say "wrong key": it says the key is not available.
C. Trusted: yes, which is the shortcut people reach for. No key, and:
$ apt-get update
Get:1 http://repo:8000/lab noble InRelease [3700 B]
Ign:1 http://repo:8000/lab noble InRelease
Get:2 http://repo:8000/lab noble/main amd64 Packages [11.4 kB]
Fetched 15.1 kB in 0s (59.6 kB/s)
W: GPG error: http://repo:8000/lab noble InRelease: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY BDB5BEC64D9C999D
exit=0
Exit code 0: apt downloaded the index and will install from it, with only a warning. With trusted: yes, a server that serves anything is trusted. Do not use it for a repository that matters.
D. A changed Release. One letter of InRelease changed on the server ("Origin: lab nobie"):
$ apt-get update
Err:1 http://repo:8000/lab noble InRelease
  The following signatures were invalid: BADSIG BDB5BEC64D9C999D Lab Repo (air-gap demo) <repo@lab.example>
E: The repository 'http://repo:8000/lab noble InRelease' is not signed.
exit=100
E. A changed package index, with Release left alone (the three Packages files rewritten with one package renamed):
$ apt-get update
Err:2 http://repo:8000/lab noble/main amd64 Packages
  File has unexpected size (11404 != 11398). Mirror sync in progress? [IP: 172.22.0.2 8000]
  Hashes of expected file:
   - Filesize:11398 [weak]
   - SHA512:22563907b2a77be93d18251044de5d0f227f14ecdb595cd8dba894fe6ebe33c7950023657191514a87b0492cea0d1c3b76c5f6bbf31115bc40384bb61ec34ff9
E: Some index files failed to download. They have been ignored, or old ones used instead.
exit=100
F. A changed package: one byte appended to libbrotli1 in the pool:
$ apt-get install --reinstall -y libbrotli1
Err:1 http://repo:8000/lab noble/main amd64 libbrotli1 amd64 1.1.0-2build2
  File has unexpected size (330731 != 330730). Mirror sync in progress? [IP: 172.22.0.2 8000]
E: Failed to fetch http://repo:8000/lab/pool/main/b/brotli/libbrotli1_1.1.0-2build2_amd64.deb  File has unexpected size (330731 != 330730). Mirror sync in progress? [IP: 172.22.0.2 8000]
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?
exit=100
Note apt's hint, "Mirror sync in progress?". It is the same message you get when you copy files into a live mirror in the wrong order: new metadata that points at packages which have not arrived, or new packages under old metadata. Applying updates in the right order (below) avoids it. Each tampered file was put back afterwards by unpacking the original tarball again.

Ubuntu: updating the mirror

New security updates, new packages: the cycle repeats on the online side, and only the differences should be carried. I added jq to the filter (it is in main), updated, and published a new snapshot:
$ aptly mirror edit -filter='curl | jq' -filter-with-deps noble-rel          (and the same for noble-upd and noble-sec)
$ aptly mirror update noble-rel
Packages filtered: 6099 -> 35.
Download queue: 3 items (369.51 KiB)
$ aptly snapshot create rel-2 from mirror noble-rel          (and upd-2, sec-2)
$ aptly snapshot merge -latest all-2 rel-2 upd-2 sec-2
$ aptly snapshot diff all-1 all-2
  Arch   | Package                                  | Version in A                             | Version in B
+ amd64  | jq                                       | -                                        | 1.7.1-3ubuntu0.24.04.2
+ amd64  | libjq1                                   | -                                        | 1.7.1-3ubuntu0.24.04.2
+ amd64  | libonig5                                 | -                                        | 6.9.9-1build1
$ aptly publish switch -batch -passphrase='' -gpg-key=4BC563A2A050666CB116017FBDB5BEC64D9C999D noble lab all-2
Cleaning up prefix "lab" components main...
Publish for snapshot lab/noble [amd64] publishes {main: [all-2]: Merged from sources: 'rel-2', 'upd-2', 'sec-2'} has been successfully switched to new snapshot.
snapshot diff is the review step: exactly which packages change between the two states, before anything leaves the building. publish switch replaces what is published, re-signs, and cleans up. The old snapshots stay, so a rollback is another switch:
$ aptly publish switch -batch -passphrase='' -gpg-key=... noble lab all-1
$ grep -c "^Package: jq$" /root/.aptly/public/lab/dists/noble/main/binary-amd64/Packages
jq entries in Packages: 0
(and a switch back to all-2 brought the one jq entry back.) To carry only the difference, I took a manifest of the published tree (the sha256 of every file) before and after, and compared them. The first attempt gave 24 "changed" files, which was wrong: comm had warned "file 2 is not in sorted order", because my two lists were sorted differently. With LC_ALL=C sort on both and a whole-line comparison, the answer was right:
$ comm -13 before.manifest after.manifest | awk '{print $2}'          (new or changed)
files before: 42   after: 45
new or changed: 11   removed: 0
./lab/pool/main/j/jq/libjq1_1.7.1-3ubuntu0.24.04.2_amd64.deb
./lab/dists/noble/InRelease
./lab/dists/noble/Release.gpg
./lab/dists/noble/main/binary-amd64/Packages.gz
./lab/dists/noble/Contents-amd64.gz
./lab/dists/noble/main/Contents-amd64.gz
./lab/dists/noble/Release
./lab/pool/main/libo/libonig/libonig5_6.9.9-1build1_amd64.deb
./lab/dists/noble/main/binary-amd64/Packages.bz2
./lab/dists/noble/main/binary-amd64/Packages
./lab/pool/main/j/jq/jq_1.7.1-3ubuntu0.24.04.2_amd64.deb
Three new packages and eight signed metadata files. The delta tarball was 422,480 bytes, against 11,630,019 for the whole tree. For a real mirror of hundreds of gigabytes, that difference is the whole point of doing it this way.
On the offline side, three rules, each shown working:
  1. Verify the carried file, and verify that the server is EXACTLY at the state the delta was built from (compare its manifest with the "before" manifest). Applying a delta to the wrong base gives a broken mirror that looks fine.
  2. Unpack the PACKAGES first and the METADATA last, so that the metadata never points at files that are not there yet (the "Mirror sync in progress?" window).
  3. Check that the result equals the "after" manifest.
$ sha256sum -c lab-delta.sha256
$ tar -C /srv/mirror -xzf lab-delta.tar.gz --wildcards './lab/pool/*'
$ tar -C /srv/mirror -xzf lab-delta.tar.gz --wildcards './lab/dists/*'
$ tar -tzf lab-delta.tar.gz | grep -c /pool/
3
$ tar -tzf lab-delta.tar.gz | grep -c /dists/
8
yes: the server tree matches before.manifest (42 files)
yes: the server tree matches after.manifest (45 files)
(the two "yes" lines are the output of my comparison of the sorted manifests.) The client then needed nothing but a normal update:
$ apt-get update
Get:1 http://repo:8000/lab noble InRelease [3700 B]
Get:2 http://repo:8000/lab noble/main amd64 Packages [12.4 kB]
Fetched 16.1 kB in 0s (159 kB/s)
$ apt-cache policy jq | head -4
jq:
  Installed: (none)
  Candidate: 1.7.1-3ubuntu0.24.04.2
$ apt-get install -y jq 2>&1 | grep -E '^(Get:|Setting up jq|[0-9]+ upgraded)'
0 upgraded, 3 newly installed, 0 to remove and 1 not upgraded.
Get:1 http://repo:8000/lab noble/main amd64 libonig5 amd64 6.9.9-1build1 [172 kB]
Get:2 http://repo:8000/lab noble/main amd64 libjq1 amd64 1.7.1-3ubuntu0.24.04.2 [142 kB]
Get:3 http://repo:8000/lab noble/main amd64 jq amd64 1.7.1-3ubuntu0.24.04.2 [65.7 kB]
Setting up jq (1.7.1-3ubuntu0.24.04.2) ...
A property of apt repositories that matters here: NOTHING expires. I looked for a Valid-Until line, which lets a Release file say when clients should stop trusting it, in Ubuntu's real Release files and in the one aptly published:
$ curl -s http://archive.ubuntu.com/ubuntu/dists/noble/Release | grep -E "^(Date|Valid-Until):"          (and the same for -updates and -security)
upstream noble           Date: Thu, 25 Apr 2024 15:10:33 UTC
upstream noble-updates   Date: Fri, 09 Oct 2026  7:53:53 UTC
upstream noble-security  Date: Fri, 09 Oct 2026  9:39:14 UTC
our published Release:    Date: Fri, 9 Oct 2026 14:41:35 UTC
There is a Date and no Valid-Until in any of them. A client will therefore accept a perfectly signed but years-old copy of the repository without a word, and a mirror that stopped being updated will never announce it. Monitor the age yourself; the lab's check, run on the Ubuntu client against the offline server:
$ curl -s http://repo:8000/lab/dists/noble/Release | awk -F': ' '/^Date:/{print $2}'     (then compare with the date)
Release says: Fri, 9 Oct 2026 14:41:35 UTC
age: 5 minutes (0 days)
and alert when the number of days exceeds the interval at which you promised to refresh the mirror.

Red Hat family: what is different

Three things differ from the Ubuntu side, and the first is the one that decides what you can do:
  1. THE CONTENT IS BEHIND A SUBSCRIPTION. Red Hat Enterprise Linux's repositories are served from Red Hat's content delivery network to systems that are subscribed (registered with subscription-manager and entitled). Rebuilds such as Rocky Linux and AlmaLinux publish their repositories openly. I could read only search-result snippets of Red Hat's own knowledge-base articles (the articles themselves are behind a login), and they say that reposync, the tool that copies a repository, needs a system that is subscribed to the repository it copies, and that for RHEL 8 and later reposync alone is enough because it downloads the metadata too. I did not test any of it. Everything below was done against Rocky, whose repositories need no subscription. The tools, the file formats and the client configuration are the same; the entitlement step is the only thing missing from my testing. Red Hat also sells Satellite for this job (a server that manages content for a whole estate); I did not read its documentation and did not run it.
  2. THE TOOL. dnf reposync (from dnf-plugins-core) does the copy, and its manual page documents the options:
       -n, --newest-only       download only the newest packages per repository
       --download-metadata     download all the repository metadata; the downloaded copy is instantly usable as a repository, no need to run createrepo_c on it
       --delete                delete local packages no longer present in the repository
       -g, --gpgcheck          remove packages that fail GPG signature checking after downloading (exit code 1 if at least one was removed)
       -p, --download-path     the directory to store the repositories in
       -u, --urls              just print the URLs of what would be downloaded
       -a, --arch              only the given architecture
     createrepo_c is the tool that builds the metadata for a directory of RPMs you assembled yourself.
  3. WHAT IS SIGNED. In the Debian world the repository metadata is signed and the packages are protected by hashes inside that signed metadata. In the RPM world each PACKAGE carries its vendor's signature (gpgcheck), and the metadata can optionally be signed too (repo_gpgcheck). A mirror normally keeps the vendor's package signatures, so clients trust the VENDOR key for packages, and your own key, if you add one, only covers the metadata.

Red Hat family: a real reposync, and a hand-built repository

First a complete, real repository copy. Rocky's "extras" repository is small, which makes it a good test. -u shows what would be downloaded:
$ dnf reposync --repoid=extras --newest-only -u
https://rocky-linux-us-south1.production.gcp.mirrors.ctrliq.cloud/pub/rocky//9.8/extras/x86_64/os/Packages/a/anaconda-live-34.25.7.14-1.el9.rocky.0.6.x86_64.rpm
https://rocky-linux-us-south1.production.gcp.mirrors.ctrliq.cloud/pub/rocky//9.8/extras/x86_64/os/Packages/c/centos-release-ceph-pacific-1.0-2.el9.noarch.rpm
...
(60 lines: the first is dnf's own status line, so 59 packages.) The real thing:
$ time dnf reposync --repoid=extras --newest-only --download-metadata -p /srv/rk
(57/59): rpmfusion-free-release-tainted-9-1.noa 149 kB/s | 7.3 kB     00:00
(58/59): update-motd-0.1-2.el9.noarch.rpm       182 kB/s |  10 kB     00:00
(59/59): rpaste-0.4.1-1.el9.x86_64.rpm          6.9 MB/s | 2.9 MB     00:00
real	0m3.650s
$ ls /srv/rk/extras
Packages
mirrorlist
repodata
$ ls /srv/rk/extras/repodata
...-other.xml.gz  ...-filelists.sqlite.bz2  ...-filelists.xml.gz  ...-primary.sqlite.bz2  ...-other.sqlite.bz2  ...-primary.xml.gz  repomd.xml
$ du -sh /srv/rk/extras
3.9M	/srv/rk/extras
59 packages in 3.9 MB, and a directory that already has its repodata: usable as a repository, as the documentation says, without createrepo_c. (The Packages directory has one sub-directory per first letter.) A full BaseOS or AppStream reposync is the same command with a different --repoid, and the sizes from the section above; I did not run those.
For a small air-gapped estate, "everything" is the wrong target. The second way builds a repository containing exactly what you need, with all its dependencies: dnf download --resolve --alldeps downloads the packages and everything they require (--alldeps includes dependencies that the online machine already has installed, which is what you want here), and createrepo_c writes the metadata:
$ dnf download --resolve --alldeps --arch x86_64 --arch noarch --destdir /srv/rk/lab/Packages curl jq
(70/71): pcre2-syntax-10.40-6.el9.noarch.rpm    2.1 MB/s | 140 kB     00:00
(71/71): coreutils-8.32-41.el9_8.1.x86_64.rpm   3.4 MB/s | 1.1 MB     00:00
$ ls /srv/rk/lab/Packages | wc -l; du -sh /srv/rk/lab/Packages
71
27M	/srv/rk/lab/Packages
71 packages for two commands, 27 MB: --alldeps brought bash, coreutils and glibc along too, so the repository can also satisfy a minimal install. The packages keep their vendor signature, and rpm can check it:
$ rpm -K /srv/rk/lab/Packages/jq-*.rpm
/srv/rk/lab/Packages/jq-1.6-19.el9_8.2.x86_64.rpm: digests signatures OK
$ rpm -qip /srv/rk/lab/Packages/jq-*.rpm | grep -E '^(Name|Version|Release|Signature)'
Name        : jq
Version     : 1.6
Release     : 19.el9_8.2
Signature   : RSA/SHA256, Wed May 20 15:00:15 2026, Key ID 702d426d350d275d
(Key ID 702d426d350d275d is Rocky's release key; for RHEL it would be Red Hat's.) Now the metadata, and then a signature of ours over it, optional but worth having:
$ createrepo_c /srv/rk/lab
Preparing sqlite DBs
Pool started (with 5 workers)
Pool finished
$ ls /srv/rk/lab/repodata
...-other.xml.gz  ...-filelists.sqlite.bz2  ...-filelists.xml.gz  ...-other.sqlite.bz2  ...-primary.sqlite.bz2  ...-primary.xml.gz  repomd.xml
$ gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key 'Lab RPM Repo (air-gap demo) <rpm@lab.example>' ed25519 sign never
key: 0ED68315205AC3F763FA62B289299EDFE9DA0049
$ gpg --batch --pinentry-mode loopback --passphrase '' --detach-sign --armor -u 0ED68315... -o repodata/repomd.xml.asc repodata/repomd.xml
$ gpg --verify repodata/repomd.xml.asc repodata/repomd.xml
gpg: Good signature from "Lab RPM Repo (air-gap demo) <rpm@lab.example>" [ultimate]
repomd.xml is the one file that lists all the others with their hashes; signing it (the .asc file next to it) protects the whole set. The public key is exported to RPM-GPG-KEY-lab.

Red Hat family: carry it, serve it, use it

The same carry as before, with the lesson applied: a relative checksum.
$ cd /srv && tar -czf /root/rk-mirror.tar.gz rk && cd /root && sha256sum rk-mirror.tar.gz > rk-mirror.sha256
aefe4bd7654a5afa443d0f568bdb3f2d6155d3aa177ecf881bff772720f1eed4  rk-mirror.tar.gz
28576116 rk-mirror.tar.gz
$ sha256sum -c rk-mirror.sha256          (on the carrying host, then again on the offline server)
rk-mirror.tar.gz: OK
rk-mirror.tar.gz: OK
$ tar -C /srv -xzf rk-mirror.tar.gz && ls /srv/rk
RPM-GPG-KEY-lab
extras
lab
$ cd /srv/rk && python3 -m http.server 8001
$ curl -sI http://localhost:8001/lab/repodata/repomd.xml | head -1
HTTP/1.0 200 OK
The client, a plain Rocky 9 image with no internet, as it comes:
$ dnf --setopt=timeout=5 --setopt=retries=1 install -y jq
Errors during downloading metadata for repository 'baseos':
  - Curl error (6): Couldn't resolve host name for https://mirrors.rockylinux.org/mirrorlist?arch=x86_64&repo=BaseOS-9 [Could not resolve host: mirrors.rockylinux.org]
Error: Failed to download metadata for repo 'baseos': Cannot prepare internal mirrorlist: Curl error (6): Couldn't resolve host name for https://mirrors.rockylinux.org/mirrorlist?arch=x86_64...
The default .repo files point at the internet, so they are moved aside (all four of them: any enabled repository that cannot be reached makes dnf fail, not just skip) and one file describes the mirror. With gpgcheck=1, every package's signature is checked:
$ cat /etc/yum.repos.d/lab.repo
[lab]
name=Lab mirror (air-gapped)
baseurl=http://rkrepo:8001/lab
enabled=1
gpgcheck=1
repo_gpgcheck=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
$ dnf install -y jq
Installing:
 jq                 x86_64          1.6-19.el9_8.2           lab          187 k
Installing dependencies:
 oniguruma          x86_64          6.9.6-1.el9.6            lab          217 k
Importing GPG key 0x350D275D:
 Userid     : "Rocky Enterprise Software Foundation - Release key 2022 <releng@rockylinux.org>"
 Fingerprint: 21CB 256A E16F C54C 6E65 2949 702D 426D 350D 275D
 From       : /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
Key imported successfully
Installed:
  jq-1.6-19.el9_8.2.x86_64            oniguruma-6.9.6-1.el9.6.x86_64
Complete!
$ rpm -qi jq | grep -E '^(Signature|Install Date)'
Install Date: Fri Oct  9 14:45:07 2026
Signature   : RSA/SHA256, Wed May 20 15:00:15 2026, Key ID 702d426d350d275d
(output shortened.) The key came from a file that is on the machine already (the vendor's key ships in the OS image and is also what gpgkey points at). With -y, dnf imported it without asking. Keep that in mind for the next section.
The lab key for the repository metadata arrived on the same disk and was checked against the hash noted on the online side, before it was put in /etc/pki/rpm-gpg:
$ echo '8096e1513507e70e6e0ad87d4f9b1f5c64453261a6c11b60433413b37c423059  /root/RPM-GPG-KEY-lab' | sha256sum -c -
/root/RPM-GPG-KEY-lab: OK

Red Hat family: what refuses to work, and why

A. A key that is not the vendor's. I removed the Rocky key from the rpm database and pointed gpgkey at my lab key. With -y:
$ dnf reinstall -y oniguruma
 From       : /etc/pki/rpm-gpg/RPM-GPG-KEY-lab
Key imported successfully
Import of key(s) didn't help, wrong key(s)?
Public key for oniguruma-6.9.6-1.el9.6.x86_64.rpm is not installed. Failing package is: oniguruma-6.9.6-1.el9.6.x86_64
 GPG Keys are configured as: file:///etc/pki/rpm-gpg/RPM-GPG-KEY-lab
Error: GPG check FAILED
The install failed, as it should. But look at the line before it: "Key imported successfully". With -y, dnf imported WHATEVER key the repository file named, and that key is now trusted in the rpm database on this machine. If an attacker could change that gpgkey line or the file behind it, -y would trust their key. The gpgkey setting is a trust decision, and -y answers it for you. Put the file in place yourself, check its hash, and review what a repository file says before any unattended run.
B. A changed package. One byte appended to oniguruma on the server:
$ dnf clean packages; dnf reinstall -y oniguruma
[MIRROR] oniguruma-6.9.6-1.el9.6.x86_64.rpm: Interrupted by header callback: Inconsistent server data, reported file Content-Length: 221927, repository metadata states file length: 221926 ...
[FAILED] oniguruma-6.9.6-1.el9.6.x86_64.rpm: No more mirrors to try - All mirrors were already tried without success
Error: Error downloading packages:
  oniguruma-6.9.6-1.el9.6.x86_64: Cannot download, all mirrors were already tried without success
The size recorded in the metadata no longer matched, so dnf refused it before even reaching the signature check.
C. repo_gpgcheck=1: the repository metadata must be signed. The lab key is added to gpgkey (two keys, one per line, so the same setting covers both packages and metadata):
gpgcheck=1
repo_gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
       file:///etc/pki/rpm-gpg/RPM-GPG-KEY-lab
$ dnf clean all; dnf -y makecache
Importing GPG key 0xE9DA0049:
 Userid     : "Lab RPM Repo (air-gap demo) <rpm@lab.example>"
 Fingerprint: 0ED6 8315 205A C3F7 63FA 62B2 8929 9EDF E9DA 0049
 From       : /etc/pki/rpm-gpg/RPM-GPG-KEY-lab
Metadata cache created.
(the Rocky key was imported just before it, as above.) Then two failures. With repomd.xml.asc missing on the server:
$ dnf clean all; dnf -y makecache
Errors during downloading metadata for repository 'lab':
  - Status code: 404 for http://rkrepo:8001/lab/repodata/repomd.xml.asc (IP: 172.22.0.4)
Error: Failed to download metadata for repo 'lab': GPG verification is enabled, but GPG signature is not available. This may be an error or the repository does not support GPG verification: Status code: 404 for http://rkrepo:8001/ ...
and with repomd.xml altered (the revision number changed) while the old signature stayed:
Error: Failed to download metadata for repo 'lab': repomd.xml GPG signature verification error: Bad GPG signature
Both were restored afterwards (checked with sha256sum; the Rocky image has no cmp or diff, which is also worth knowing before you write a verification script for such a system).

Red Hat family: updating, and the cache trap

Same cycle: add the package (tree), refresh the metadata, sign it again, work out the delta against the offline server's manifest, carry, verify, apply.
$ dnf download --resolve --alldeps --arch x86_64 --arch noarch --destdir /srv/rk/lab/Packages tree
(16/16): tree-1.8.0-10.el9.x86_64.rpm            79 kB/s |  55 kB     00:00
$ createrepo_c --update /srv/rk/lab
$ gpg ... --detach-sign --armor -u 0ED68315... -o repodata/repomd.xml.asc repodata/repomd.xml
files now: 148
new or changed: 9   removed: 6
./lab/repodata/repomd.xml.asc
./lab/Packages/tree-1.8.0-10.el9.x86_64.rpm
./lab/repodata/repomd.xml
... (six more files in repodata)
$ ls -l rk-delta.tar.gz rk-mirror-2.tar.gz
379202 rk-delta.tar.gz
28629170 rk-mirror-2.tar.gz
Only ONE package was new (tree needed nothing that was not in the repository already), but six metadata files must be DELETED: createrepo_c names its metadata files after their hashes, so an update writes new files and the old ones become orphans. The delta therefore has a removed list as well as a new-or-changed list. On the offline server, in the order that matters: packages, then metadata, then the repomd.xml pair, then the deletions.
$ tar -C /srv/rk -xzf rk-delta.tar.gz --wildcards './lab/Packages/*'
$ tar -C /srv/rk -xzf rk-delta.tar.gz --wildcards './lab/repodata/*-*'
$ tar -C /srv/rk -xzf rk-delta.tar.gz --wildcards './lab/repodata/repomd.xml' './lab/repodata/repomd.xml.asc'
$ while read -r f; do rm -f "/srv/rk/$f"; done < rk-removed.list
yes: matches the before-manifest (147 files)
yes: matches the after-manifest (148 files)
(repomd.xml goes in LAST of the metadata, because it is the file that makes the new set visible. The two "yes" lines are my comparisons of the sorted manifests.)
Now the trap. The client had built its dnf cache before the update, and asked for the new package:
$ dnf install -y tree
Last metadata expiration check: 0:00:30 ago on Fri Oct  9 14:45:57 2026.
No match for argument: tree
Error: Unable to find a match: tree
exit=1
The package was on the server, and dnf said it did not exist. dnf keeps repository metadata for a while (its metadata_expire setting; I did not re-read its documentation today, and what I observed is that a cache 30 seconds old was trusted and the server was not asked) and does not look at the server again until it expires. Tell it to expire the cache:
$ dnf clean expire-cache
$ dnf install -y tree
Lab mirror (air-gapped)                         7.3 kB/s | 228  B     00:00
Lab mirror (air-gapped)                         3.6 MB/s | 135 kB     00:00
Installing:
 tree            x86_64            1.8.0-10.el9            lab             55 k
Complete!
$ rpm -qi tree | grep -E '^Signature'
Signature   : RSA/SHA256, Mon May 16 12:23:23 2022, Key ID 702d426d350d275d
(dnf --refresh does the same for one command.) apt has no equivalent trap, because apt-get update always asks the server. But apt has the opposite problem described above: nothing ever expires.

Error messages

Each of these ran in the lab.
  Temporary failure resolving 'archive.ubuntu.com'     the client still has the default Ubuntu sources: move ubuntu.sources aside
  NO_PUBKEY BDB5BEC64D9C999D / The repository ... is not signed    no key, or a key that is not this repository's (apt does not distinguish the two)
  The following signatures were invalid: BADSIG ...    the Release file was changed after signing
  File has unexpected size (A != B). Mirror sync in progress?    a file differs from what the signed metadata says: a damaged or tampered file, or a copy in the wrong order
  E: Unable to locate package tree    the package is in a component or architecture you did not mirror (aptly gave no warning)
  Packages filtered: 5715 -> 13     (aptly) how many packages a filter kept: read it, it is your only hint that a filter matched too little
  Errors during downloading metadata for repository 'baseos' ... Curl error (6)   dnf still has the default repositories enabled
  Public key for X.rpm is not installed / GPG check FAILED     the package's signing key is not in the rpm database (and "Key imported successfully" just before it means -y imported the wrong one)
  Inconsistent server data, reported file Content-Length ... repository metadata states file length ...    a changed RPM
  GPG verification is enabled, but GPG signature is not available ... Status code: 404 for .../repomd.xml.asc    repo_gpgcheck=1 and no signature file
  repomd.xml GPG signature verification error: Bad GPG signature    repomd.xml does not match its signature
  No match for argument: tree    the package is not in dnf's CACHED metadata: dnf clean expire-cache

What was not run in the lab

  - Real Red Hat Enterprise Linux content: subscription-manager, entitlement certificates, Red Hat's content delivery network, Satellite, and Red Hat's own knowledge-base articles (read only as search snippets)
  - Other tools for the same job: apt-mirror, debmirror, reprepro, apt-cacher-ng (which is a caching proxy and is NOT an air-gap solution), rsync from a public mirror, Pulp, Landscape and Ubuntu Pro mirroring (Canonical's documentation was behind a login), Ubuntu's ESM and livepatch services in an air gap
  - A FULL mirror of anything: the lab mirrored a few packages and their dependencies (22 MB and 27 MB). The sizes of the full repositories are from their metadata; download time, disk throughput and the cost of a daily sync were not measured
  - Source packages (deb-src, SRPMs), debug repositories, other architectures (arm64, i386; the apt "Architectures" line decides what a client asks for, see the runbook "apt update Fails on i386" on this site)
  - AppStream modules (the modular metadata, modules.yaml): not part of the hand-built repository, and not tested with reposync of AppStream
  - HTTPS for the internal repository (everything here used plain HTTP, with signed metadata for integrity), client certificates, a real web server (nginx, Apache) and its tuning, a load balancer in front of several repo servers
  - Time: clients with a wrong clock, key rotation, key expiry (my keys never expire: "sign never"), revoking a repository key, and protecting the signing key (it had no passphrase)
  - The online side with a proxy or a corporate firewall, mirrors that fail halfway (aptly mirror update is meant to be re-run; dnf reposync skips what is already there)
  - Debian, SUSE, and other distributions

Recipes

Size a mirror before building it (Ubuntu):         read Packages.xz of every pocket/component and add up 'Size:'
Size a mirror (Red Hat family):                    dnf repoquery --disablerepo='*' --enablerepo=baseos --available --queryformat '%{downloadsize}\n' | awk '{s+=$1} END{print s/1e9 " GB"}'
Which component is a package in (Ubuntu):          apt-cache show PACKAGE | awk '/^Filename:/{print $2}'
Mirror a filtered set (Ubuntu):                    aptly -architectures=amd64 mirror create -filter='curl | jq' -filter-with-deps NAME URL SUITE COMPONENT
Verify upstream first (Ubuntu):                    gpg --no-default-keyring --keyring trustedkeys.gpg --import /usr/share/keyrings/ubuntu-archive-keyring.gpg
Freeze and merge (Ubuntu):                         aptly snapshot create S from mirror M      aptly snapshot merge -latest ALL S1 S2 S3
Review a change (Ubuntu):                          aptly snapshot diff OLD NEW
Publish / roll forward / roll back (Ubuntu):       aptly publish snapshot -distribution=noble -gpg-key=KEY ALL lab      aptly publish switch noble lab NEWER
Client (Ubuntu): key + sources:                    /etc/apt/keyrings/NAME.gpg   and   Signed-By: /etc/apt/keyrings/NAME.gpg   in a deb822 .sources file
Copy a whole repository (Red Hat family):          dnf reposync --repoid=ID --newest-only --download-metadata -p DIR
See what it would fetch:                           dnf reposync --repoid=ID --newest-only -u | wc -l
Build a small repository from a package list:      dnf download --resolve --alldeps --destdir DIR/Packages PKG...      createrepo_c DIR
Update it:                                         createrepo_c --update DIR      (new metadata files, old ones become orphans)
Sign the metadata:                                 gpg --detach-sign --armor -u KEY -o DIR/repodata/repomd.xml.asc DIR/repodata/repomd.xml
Client (Red Hat family):                           [lab] baseurl=... gpgcheck=1 repo_gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/... (one key per line)
Carry a delta safely:                              manifest before and after (LC_ALL=C sort), comm -13, tar -T list; checksum with a RELATIVE file name; verify the base state; packages first, metadata last
Is the mirror stale:                               compare the Date: line of Release (apt) or the revision/timestamps in repomd.xml (dnf) with today
Force dnf to look again:                           dnf clean expire-cache      or      dnf --refresh install PKG

Common problems

Symptom                                          Usually
apt update still tries archive.ubuntu.com        ubuntu.sources (or sources.list) not moved aside on the client
NO_PUBKEY after setting up the mirror            the key file is not where Signed-By says, or not the repository's key; there is no other message
Package "not found" though you mirrored it       it is in another component (universe, restricted...) or architecture than you mirrored; the filter said nothing
"Mirror sync in progress?" on a mirror you control   a file does not match the signed metadata: damaged copy, or metadata applied before packages. Re-copy in the right order
Clients accept an old mirror happily             Release files have no Valid-Until: monitor the Date yourself
dnf: "Failed to download metadata for repo baseos"   the default repo files are still enabled on an offline machine
dnf: "No match" for a package that is on the server    the cache: dnf clean expire-cache
dnf imports a key you did not expect             -y answers the import question for whatever gpgkey names: put the key file in place yourself and check its hash
A checksum file fails on another machine         it contains an absolute path: create it with a relative name from inside the directory
Delta applied, repository broken                 the server was not at the state the delta was built from, or the metadata was applied before the packages
Mirror is far bigger than expected               nvidia kernel-module packages (restricted), or old versions kept by the repository: filter, or --newest-only
Old repodata files pile up on the offline side   createrepo_c names metadata after hashes: delete what the new repomd.xml no longer lists

Tips

Start from the size question: choose components, pockets, architectures and a package list before you build anything.
Verify upstream signatures when you mirror (aptly does, if you give it the key), and sign what you publish.
Treat keys as a trust decision made out of band: carry the key with the mirror, check its hash against a note you made on the online side, then place it yourself.
Never use Trusted: yes or an unattended dnf -y on a repository file you have not read.
Carry deltas, not the whole thing, and prove both ends: checksum of the carried file, manifest of the base state before, manifest of the result after.
Apply packages first and metadata last; apply deletions after.
Keep old snapshots so that a bad update is one publish switch away from being undone.
Put the age of the mirror on a dashboard: neither apt nor dnf will tell you.
Keep the online and offline sides' tool versions and distribution releases aligned, and test an update on a client that is a copy of production before you roll it out.
Only mirror what you have a right to copy, and treat the carrying medium as part of your security perimeter: a disk that goes both ways is a way out of the gap as well as in.