Skip to content

Split View: 폐쇄망 안에 로컬 저장소 만들기 — createrepo_c, repodata, .repo 파일과 GPG 키

✨ Learn with Quiz
|

폐쇄망 안에 로컬 저장소 만들기 — createrepo_c, repodata, .repo 파일과 GPG 키

들어가며 — rpm이 모인 디렉터리는 아직 저장소가 아닙니다

2편에서 만든 것은 rpm 파일이 담긴 디렉터리입니다. 그 상태로 폐쇄망에 들고 들어가서 dnf install을 하면 여전히 의존성을 못 찾습니다.

dnf는 rpm 파일을 하나씩 열어 보지 않기 때문입니다. dnf가 읽는 것은 저장소 메타데이터이고, 그건 별도로 생성해 줘야 합니다. 그 생성 도구가 createrepo_c입니다.

이번 편에서는 그 메타데이터를 만들고, 저장소로 등록하고, 서명을 검증하게 만들고, 마지막으로 폐쇄망에서 가장 자주 사람을 잡는 캐시 문제까지 정리합니다.

createrepo_c로 메타데이터 만들기

가장 기본 형태입니다.

# 저장소 도구 설치 (연결망 장비에서 미리 받아 함께 반입해야 합니다)
sudo dnf install createrepo_c

# 디렉터리를 저장소로 만든다
sudo createrepo_c /srv/repo/rhel9-baseos

명령이 끝나면 대상 디렉터리 안에 repodata가 생깁니다. 안을 들여다보면 구조가 보입니다.

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

repomd.xml이 색인이고 나머지가 실제 데이터입니다. primary는 패키지의 이름과 버전, 의존성 정보를, filelists는 패키지가 담고 있는 파일 목록을, other는 changelog를 담습니다. 파일 이름 앞에 체크섬이 붙는 이유는 man 페이지에 나옵니다. --unique-md-filenames가 "Include file's checksum in metadata filename for HTTP caching (default)"이고 기본값입니다. 내용이 바뀌면 파일 이름도 바뀌므로 중간 프록시가 오래된 사본을 돌려주는 사고를 막아 줍니다.

실무에서 자주 쓰는 옵션은 이 정도입니다.

# 패키지를 추가한 뒤 전체 재생성 대신 증분 갱신
sudo createrepo_c --update /srv/repo/rhel9-baseos

# 워커를 늘려 대형 저장소 생성 시간을 줄인다
sudo createrepo_c --workers 8 /srv/repo/rhel9-baseos

# 패키지 그룹(comps) 정보를 함께 포함시킨다
sudo createrepo_c --groupfile /srv/repo/comps.xml /srv/repo/rhel9-baseos

man 페이지 기준으로 각 옵션의 정의는 이렇습니다.

옵션man 페이지 설명
--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는 man 페이지에 "DEPRECATED: Generate sqlite databases for use with yum"이라고 표시되어 있습니다. 오래된 절차서에서 이 옵션을 보면 지워도 됩니다.

--pkglist는 폐쇄망에서 특히 쓸모가 있습니다. 4편에서 만들 반입 매니페스트를 그대로 입력으로 넘기면, 매니페스트에 적힌 것만 정확히 저장소에 들어갑니다. 목록과 실제 저장소가 어긋나는 사고를 구조적으로 막는 방법입니다.

.repo 파일로 등록하기

메타데이터가 있어도 dnf가 그 위치를 모르면 소용없습니다. RHEL 9 문서는 명확합니다. 저장소는 /etc/dnf/dnf.conf/etc/yum.repos.d/ 아래 .repo 파일에 정의하며, "Define your repositories in the .repo file instead of /etc/dnf/dnf.conf"라고 권고합니다.

서버 한 대에서 로컬 디렉터리를 직접 쓰는 경우입니다.

# /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

여러 서버에 배포한다면 사내 HTTP 서버 한 대를 세우고 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

이름에 스냅샷 날짜를 박아 둔 것은 취향이 아닙니다. 6개월 뒤 이 서버가 어느 시점 콘텐츠로 설치됐는지 알아내는 가장 빠른 방법이 dnf repolist -v 출력이기 때문입니다.

dnf 설정 문서 기준으로 각 항목의 의미는 이렇습니다. baseurl은 "List of URLs for the repository", enabled는 "Include this repository as a package source. The default is True", gpgcheck는 "Whether to perform GPG signature check on packages found in this repository. The default is False", gpgkey는 "URLs of a GPG key files that can be used for signing metadata and packages of this repository", priority는 "The priority value of this repository, default is 99"입니다.

gpgcheck의 기본값이 거짓이라는 점을 반드시 기억해야 합니다. 적어 두지 않으면 검사하지 않습니다. 폐쇄망에서는 이게 특히 위험한데, 이유는 4편에서 다룹니다.

명령으로 저장소를 추가할 수도 있고, 여기서 버전 차이가 나옵니다.

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

# RHEL 8 (공식 문서 표기)
sudo yum-config-manager --add-repo http://repo.internal.example.com/rhel8/baseos

RHEL 8 공식 문서는 yum-config-manager --add-repo로, RHEL 9와 RHEL 10 문서는 dnf config-manager --add-repo로 적고 있습니다. 두 문서 모두 "repositories added by this command are enabled by default"라고 덧붙입니다.

GPG 키를 반입하고 등록하기

gpgcheck=1로 켜 두면 첫 설치에서 키를 요구합니다. 폐쇄망에는 키를 자동으로 가져올 경로가 없으므로 키 파일도 반입 대상입니다.

키 파일의 정확한 경로와 이름은 배포판과 버전에 따라 다르므로, 이 글은 특정 파일명을 단정하지 않습니다. 연결망 장비에서 먼저 확인하고 그 파일을 번들에 담으세요. 아래 예시의 파일명은 확인한 실제 이름으로 바꿔 써야 합니다.

# 먼저 이 시스템이 어떤 GPG 키 파일을 갖고 있는지 확인한다
ls -1 /etc/pki/rpm-gpg/

# 릴리스 패키지가 설치한 키 파일을 정확히 짚는다
rpm -ql redhat-release | grep -i gpg

# 현재 저장소 설정이 어떤 키를 참조하는지 확인한다
grep -h '^gpgkey=' /etc/yum.repos.d/*.repo | sort -u

# 확인한 키를 반입해 표준 위치에 배치
sudo cp ./keys/RPM-GPG-KEY-redhat-release /etc/pki/rpm-gpg/

# 키를 rpm 키링에 등록한다
sudo rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release

# 등록된 키 확인
rpm -q gpg-pubkey --qf '%{NAME}-%{VERSION}-%{RELEASE} %{SUMMARY}\n'

여기서 버전 관련 주의사항이 하나 있습니다. 최신 업스트림 RPM의 man 페이지는 -K, --checksig, --import를 "Obsolete compatibility aliases"로 분류하고 rpmkeys(8)을 보라고 안내합니다. RHEL 8·9·10에서는 rpm --import가 그대로 동작하지만, 앞으로를 생각하면 rpmkeys 표기를 쓰는 편이 안전합니다.

# 앞으로 권장되는 표기
sudo rpmkeys --import /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release

# 등록된 키를 지문과 사용자 ID로 나열
rpmkeys --list

rpmkeys man 페이지는 -i, --import를 "Import ASCII-armored public keys. Digital signatures cannot be verified without the corresponding public key (aka certificate)", -l, --list를 "List currently imported public key(s) (aka certificates) by their fingerprint and user ID"로 정의합니다.

패키지 서명과 메타데이터 서명은 다른 설정이라는 점도 짚어 둡니다. gpgcheck는 패키지에 대한 검사이고, repo_gpgcheck는 "Whether to perform GPG signature check on this repository's metadata"이며 기본값은 역시 거짓입니다. 직접 만든 저장소의 메타데이터에 서명을 붙이려면 별도 절차가 필요하고, 붙이지 않았다면 repo_gpgcheck를 켜면 안 됩니다.

캐시가 갱신을 삼킵니다

폐쇄망에서 가장 자주 나오는 신고가 이겁니다. 저장소에 패키지를 추가했는데 서버에서 안 보인다는 것.

원인은 대개 메타데이터 캐시입니다. dnf 설정 문서는 metadata_expire를 "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"로 정의합니다. 즉 기본 상태에서는 최대 이틀 동안 옛 메타데이터를 그대로 씁니다.

저장소를 갱신했다면 이렇게 처리합니다.

# 1. 저장소 쪽: 메타데이터를 다시 만든다 (이걸 빼먹는 경우가 정말 많습니다)
sudo createrepo_c --update /srv/repo/rhel9-baseos

# 2. 클라이언트 쪽: 캐시를 만료 표시만 한다 (가장 가벼움)
sudo dnf clean expire-cache

# 3. 그래도 안 되면 메타데이터 캐시를 지운다
sudo dnf clean metadata

# 4. 최후의 수단 — 전부 지운다
sudo dnf clean all

# 5. 캐시를 미리 다시 채운다
sudo dnf makecache

dnf 문서의 정의는 각각 expire-cache가 "Marks the repository metadata expired", metadata가 "Removes repository metadata", packages가 "Removes any cached packages from the system", all이 "Does all of the above"입니다. 위에서부터 순서대로 시도하는 편이 좋습니다. dnf clean all은 폐쇄망에서 특히 아까운데, 지운 캐시를 다시 채울 인터넷이 없기 때문입니다. 다만 반입 저장소는 로컬이라 재생성 비용이 낮습니다.

앞의 .repo 예시에서 metadata_expire=-1을 쓴 이유가 여기 있습니다. 반입 저장소는 사람이 명시적으로 갱신하기 전까지 절대 바뀌지 않으므로, 만료로 인한 재검사가 의미 없습니다. 갱신 시점은 사람이 dnf clean expire-cache로 알려 주는 편이 예측 가능합니다.

저장소가 실제로 동작하는지 확인하기

등록이 끝났으면 세 가지를 확인합니다.

# 1. 저장소가 인식되고 패키지 수가 0이 아닌가
dnf repolist -v

# 2. 이 저장소만으로 의존성이 다 풀리는가
dnf repoclosure --repo=airgap-baseos --repo=airgap-appstream

# 3. 실제 설치가 되는가 (트랜잭션만 확인하고 멈춤)
sudo dnf install --assumeno httpd

2번은 2편에서 소개한 repoclosure입니다. 반입 전에 한 번, 반입 후 실제 저장소 구성에서 한 번 더 돌리는 것이 좋습니다. 반입 과정에서 파일이 누락되는 일이 실제로 일어나기 때문입니다.

서브스크립션 없이 Red Hat 콘텐츠를 재배포하는 것은 계약 위반일 수 있으니 조직의 라이선스 조건을 먼저 확인하세요. 특히 사내 HTTP 미러는 "재배포"의 성격을 띠므로, 구성 전에 계약 조건을 확인하는 편이 안전합니다.

마치며 — 저장소는 파일이 아니라 계약입니다

createrepo_c 자체는 명령 한 줄입니다. 어려운 부분은 그 뒤에 있습니다.

메타데이터를 다시 만들었는가, 키를 반입해 등록했는가, gpgcheck를 명시했는가, 캐시를 만료시켰는가. 이 네 가지가 폐쇄망 저장소 장애의 대부분을 차지합니다. 넷 다 확인 명령이 한 줄씩이니 절차서에 그대로 넣어 두면 됩니다.

명령과 옵션은 2026-08-15에 공식 문서에서 확인했습니다. RHEL 버전에 따라 다르므로 사용 중인 버전의 문서로 다시 확인하세요.

직접 해보기

  • 리눅스 터미널 — 저장소 디렉터리 구조를 만들어 보며 경로 감각 익히기
  • chmod 계산기 — HTTP로 배포할 저장소 디렉터리의 권한 설계
  • 해시 생성기 — repomd.xml의 체크섬 개념을 직접 확인해 보기

이전 / 다음 편

참고 자료

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

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