Holger guards your artifacts at rest
Holger is a secure artifact gateway in pure Rust. It pulls packages from the internet (PyPI, npm, Maven, crates.io, OCI, Go, APK), verifies, scans and approves each one, and serves only what passed. An on-prem downstream takes the approved set as a signed bundle, over the network or on a USB stick, and serves the site's builds with no other network.
Install: four products
Holger is four products, one crate each:
| product | install | from a clone | crates | binary | what it is |
holger |
cargo install holger |
cargo install --locked --path server/cli |
349 | 58.0 MB | the server: the twelve formats, the intake chain (scanners, approvals, audit), the offline bundle and intel verbs |
holger-downstream |
cargo install holger-downstream |
cargo install --locked --path holger-downstream |
328 | 48.7 MB | the airgapped downstream: takes packages from ONE upstream Holger (a carried bundle or a sync), verifies them, serves the twelve formats, runs no scanner and dials nothing else |
holger-ui |
cargo install holger-ui |
cargo install --locked --path ../holger-ui/crates/holger-ui |
98 | 6.8 MB | the web console: the console bundle and a same-origin pass-through to the holger it points at |
holger-cli |
cargo install holger-cli |
cargo install --locked --path holger-cli |
240 | 22.2 MB | the command-line tool: every console action as a command (it dials only --at), the airgap bundle, the offline verifier and the courier |
The extras are features of the same crates: --features all-formats (NuGet, gems, conda, Composer), online (the pull-through clients), federation (Artifactory, Nexus, AWS and Azure through Skidbladnir), and full (all three).
A clone needs no registered key: git clone ssh://anonymous@git.gunnar.rs:2222/nordisk/holger.git (and nordisk/holger-ui beside it for the console). The default of every product dials nothing: docs/v2/02-install.md.
Three products by hand
This guide installs the server, the console and the downstream from their crates, connects the downstream to the server online, serves a pip package through the downstream, and then carries the same package over an airgap as a signed bundle. Every command is copy-pasteable on one Linux host. You need cargo, rootless podman, openssl, curl, python3 and pip. The integration test cargo xtask products runs exactly these blocks, in this order.
0. The places and the names. Everything lives under $G; the two Holgers and the console listen on loopback.
export G="${G:-$HOME/holger-guide}"
export HQ="${HQ:-127.0.0.1:8080}" HQ_GRPC="${HQ_GRPC:-127.0.0.1:50051}"
export DS="${DS:-127.0.0.1:8090}" DS_GRPC="${DS_GRPC:-127.0.0.1:50061}"
export UI="${UI:-127.0.0.1:3210}" AIR="${AIR:-127.0.0.1:8095}" AIR_GRPC="${AIR_GRPC:-127.0.0.1:50065}"
mkdir -p "$G"/{pki,hq,ds,air,stick,dl}
1. The server
Install the server and the command-line tool:
cargo install holger holger-cli
Every scan runs in a container, never in the server. Build the scan worker's image once (a static program in an image FROM scratch; no registry is reached):
rustup target add x86_64-unknown-linux-musl
cargo install --target x86_64-unknown-linux-musl --root "$G/worker" holger-scan-worker
mkdir -p "$G/worker/ctx" && cp "$G/worker/bin/holger-scan-worker" "$G/worker/ctx/"
printf 'nonroot:x:65532:65532:nonroot:/scratch:/sbin/nologin\n' > "$G/worker/ctx/passwd"
printf 'nonroot:x:65532:\n' > "$G/worker/ctx/group"
printf '%s\n' 'FROM scratch' 'COPY --chmod=0555 holger-scan-worker /holger-scan-worker' \
'COPY passwd /etc/passwd' 'COPY group /etc/group' 'USER 65532:65532' \
'ENV HOME=/scratch TMPDIR=/scratch' 'ENTRYPOINT ["/holger-scan-worker"]' 'CMD ["/in"]' > "$G/worker/ctx/Containerfile"
podman build --pull=never --network=none -t localhost/holger-scan-worker:3.1.0 "$G/worker/ctx"
The keys: the server's evidence signer (a P-256 leaf under its own root), and the two people of a bundle (an approver and a distinct release signer, Ed25519). The tokens: an importer, a security approver, an admin, an auditor, and the downstream's own.
cd "$G/pki"
umask 077 # every key and token here is the user's alone: Holger refuses one others can read (holger-key-1)
openssl ecparam -name prime256v1 -genkey -noout -out scan-root.key
openssl req -x509 -new -key scan-root.key -subj "/CN=hq scan root" -days 30 \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,digitalSignature" -outform der -out scan-root.der
openssl x509 -inform der -in scan-root.der -out scan-root.pem
openssl ecparam -name prime256v1 -genkey -noout -out scan.key
openssl pkcs8 -topk8 -nocrypt -in scan.key -outform der -out scan.pk8
openssl req -new -key scan.key -subj "/CN=hq scanners" -out scan.csr
printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n' > leaf.ext
openssl x509 -req -in scan.csr -CA scan-root.pem -CAkey scan-root.key -CAcreateserial -days 30 -extfile leaf.ext -outform der -out scan.der
for who in reviewer releaser; do
openssl genpkey -algorithm ed25519 -out $who.key
openssl req -x509 -key $who.key -subj "/CN=$who" -days 30 -out $who.crt
openssl x509 -in $who.crt -outform der -out $who.der
openssl pkcs8 -topk8 -nocrypt -in $who.key -outform der -out $who.pk8
done
for who in ida bob adm aud lab; do openssl rand -hex 24 > $who.token; done
fp() { openssl x509 -in "$1" -outform der | sha256sum | cut -d' ' -f1; }
policy() { cat <<EOF
(schema_version: "holger.bundle-trust-policy/v1", trust_mode: Some(PinnedSigners), trusted_roots: [],
approved_signers: [(fingerprint: "$(fp reviewer.crt)", roles: ["security-reviewer"])],
required_approval_roles: ["security-reviewer"], minimum_distinct_approvers: 1,
trusted_release_signers: ["$(fp releaser.crt)"], release_signer_must_be_distinct: true,
require_expiry: true, on_expired_metadata: Some(ReadOnly)$1)
EOF
}
policy "" > trust-policy.ron
policy ', allowed_products: ["holger.intel/v1"]' > intel-trust.ron
umask 022
cd "$G"
The server's RON: the intake chain's four repositories and a bundles repository (what the downstream pulls), the five tokens and their duties, the evidence signer, the offline advisory database, and the sync key that signs what it sends downstream.
repo() { printf '(ron_name: "%s", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: Some((ron_storage_endpoint: "disk", ron_exposed_endpoint: "door")), ron_data_dir: Some("%s/%s")),\n' "$1" "$2" "$1"; }
cat > hq.ron <<EOF
(
exposed_endpoints: [ (ron_name: "door", ron_url: "$HQ_GRPC", ron_http_url: Some("$HQ")) ],
storage_endpoints: [ (ron_name: "disk", ron_storage_type: "znippy", ron_path: "$G/hq/store") ],
repositories: [ $(for r in incoming quarantine approved production bundles; do repo $r "$G/hq"; done) ],
audit: (dir: "$G/hq/audit", enabled: true),
auth: Some((
methods: [
StaticToken(subject: "ida", token: Some("$(cat pki/ida.token)")),
StaticToken(subject: "bob", token: Some("$(cat pki/bob.token)")),
StaticToken(subject: "adm", token: Some("$(cat pki/adm.token)")),
StaticToken(subject: "aud", token: Some("$(cat pki/aud.token)")),
StaticToken(subject: "lab", token: Some("$(cat pki/lab.token)")),
],
roles: { "ida": Writer, "adm": Admin, "aud": Reader, "lab": Reader },
duties: { "ida": [Importer], "bob": [SecurityApprover], "adm": [RepositoryAdmin], "aud": [Auditor] },
)),
attestation: Some((trust_roots: ["$G/pki/scan-root.der"], signer_key: Some("$G/pki/scan.pk8"), signer_cert: Some("$G/pki/scan.der"), signer_alg: Some("ecdsa-p256"))),
intel: Some((db_dir: "$G/hq/intel-db", offline: true, trust_policy: Some("$G/pki/intel-trust.ron"))),
sync: Some((name: "hq")),
sources: [ (name: "team", kind: upload) ],
steps: (approvals: 1, unknown_license: Pass, scan: [inspection, vulnerability, license, sbom]),
rules: (require_signed: false, require_provenance: false),
)
EOF
The vulnerability scan reads a local advisory database, never the network. Fill it from the public feeds (download them on a connected machine; the server itself dials nothing), signed as an intel bundle by the same two people. The first bundle of a kind sets the anti-rollback floor, and only an admin's explicit grant lets it (allow-first-import, used once, audited). Then start the server:
mkdir -p feeds
curl -fsSLo feeds/osv-PyPI.zip https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip
curl -fsSLo feeds/kev.json https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
holger intel build --feeds-dir feeds --policy pki/intel-trust.ron --release-cert pki/releaser.der --release-key pki/releaser.pk8 \
--approve "security-reviewer=pki/reviewer.der,pki/reviewer.pk8" --out stick/intel.holger.znippy
holger bundle allow-first-import --config hq.ron --from-bundle stick/intel.holger.znippy --policy pki/intel-trust.ron
holger intel import stick/intel.holger.znippy --config hq.ron
holger start --config hq.ron > hq.log 2>&1 &
until curl -fsS "http://$HQ/healthz" > /dev/null 2>&1; do sleep 0.2; done
2. The console
cargo install holger-ui
HOLGER_UI_BIND="$UI" HOLGER_UI_HOLGER="$HQ_GRPC" HOLGER_UI_DOOR="$HQ" holger-ui > ui.log 2>&1 &
until curl -fsS "http://$UI/health" > /dev/null 2>&1; do sleep 0.2; done
Open http://127.0.0.1:3210/login and paste the admin's token (cat $G/pki/adm.token). The console keeps it as its session and every call goes to the server with it. The same sign-in, by hand:
curl -fsS -c ui.cookies -H 'content-type: application/json' -d "{\"token\":\"$(cat pki/adm.token)\"}" "http://$UI/-/console/token"
curl -fsS -H "Cookie: __Host-holger_session=$(cat pki/adm.token)" "http://$UI/-/sync/board"
3. The downstream, online
A downstream is one-way by default: its packages come in only through its inbox, and it refuses any way out. Online, it fetches from its one upstream: install it with fetch, and its RON says boundary: Fetch. Write its RON (the same intake chain, an inbox the bundles land in, the server's evidence roots it trusts, and the server as its one upstream), and print its sync key:
cargo install holger-downstream --features fetch
cat > ds.ron <<EOF
(
exposed_endpoints: [ (ron_name: "door", ron_url: "$DS_GRPC", ron_http_url: Some("$DS")) ],
storage_endpoints: [ (ron_name: "disk", ron_storage_type: "znippy", ron_path: "$G/ds/store") ],
repositories: [ $(for r in incoming quarantine approved production; do repo $r "$G/ds"; done)
(ron_name: "inbox", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: None, ron_data_dir: Some("$G/ds/bundle.inbox")) ],
audit: (dir: "$G/ds/audit", enabled: true),
boundary: Fetch,
attestation: Some((trust_roots: ["$G/pki/scan-root.der"], enrolled_signers: ["$G/pki/scan.der"])),
sync: Some((name: "lab", upstreams: [ (name: "hq", key: "$(curl -fsS -H "Authorization: Bearer $(cat pki/lab.token)" "http://$HQ/-/sync/key" | python3 -c 'import json,sys; print(json.load(sys.stdin)["key"])')", invite: Some("$G/lab.invite.json"), token_file: Some("$G/pki/lab.token")) ])),
mirror: Some((ron_state_dir: "$G/ds/mirror", ron_sources: [ (ron_name: "hq", ron_kind: Holger, ron_endpoint: "http://$HQ_GRPC",
ron_credential: Anonymous, ron_every_secs: 2, ron_retry_secs: 2,
ron_repositories: [ (ron_from: "bundles", ron_to: "inbox", ron_unproven: TrustSize) ]) ])),
steps: (trust_policy: Some("$G/pki/trust-policy.ron")),
)
EOF
holger-downstream sync key --config ds.ron
Enrol it on the server and invite it: in the console, Sync board, Enrol a downstream (name lab, the key it printed, via front), then Invite on its row, which hands out the signed invite (lab.invite.json). The same with the command-line tool. The invite is signed with the server's sync key, for this downstream's key, and names the server's sync door: the downstream's RON names the invite instead of a url:, and its start verifies it once and keeps it. The downstream's admin also grants the first bundle of the server's pypi product the right to set its anti-rollback floor (without the grant, the first bundle is refused: nothing was ever accepted here):
LAB_KEY=$(holger-downstream sync key --config ds.ron | awk '{print $NF}')
curl -fsS -X POST -H "Authorization: Bearer $(cat pki/adm.token)" "http://$HQ/-/sync/downstreams/enrol?name=lab&key=$LAB_KEY&via=front"
holger-cli --at "$HQ_GRPC" --token-file pki/adm.token downstream invite --name lab --out lab.invite.json
holger-downstream bundle allow-first-import --config ds.ron --vendor hq --product pypi --sequence 1
holger-downstream start --config ds.ron > ds.log 2>&1 &
until curl -fsS "http://$DS/healthz" > /dev/null 2>&1; do sleep 0.2; done
grep "invited this downstream directly" ds.log
4. A pip package through the downstream
Upload a wheel to the server as the importer, walk it through the intake chain (scanned, policy-checked, approved by somebody who did not import it, sealed, available), export it as a signed bundle and publish the bundle where the downstream pulls from:
pip download --no-deps --only-binary=:all: -d dl idna==3.10
WHEEL=$(basename dl/idna-*.whl)
curl -fsS -X PUT -H "Authorization: Bearer $(cat pki/ida.token)" --data-binary @"dl/$WHEEL" "http://$HQ/incoming/$WHEEL"
cli() { who=$1; shift; holger-cli --at "$HQ_GRPC" --token-file "pki/$who.token" "$@"; }
cli ida intake advance --name "$WHEEL" --reason "scan"
cli ida intake advance --name "$WHEEL" --reason "policy"
cli bob intake advance --name "$WHEEL" --reason "approved"
for s in sealed promoted available; do cli adm intake advance --name "$WHEEL" --reason "$s"; done
cli aud intake state --name "$WHEEL"
cli adm bundle export --out bundle-1 --bundle-id idna-1 --sequence 1 --supplier hq --product pypi --release-version 1
holger-cli bundle approve --candidate bundle-1 --role security-reviewer --cert pki/reviewer.crt --key pki/reviewer.key
holger-cli bundle finalize --candidate bundle-1 --policy pki/trust-policy.ron --release-cert pki/releaser.crt --release-key pki/releaser.key --output stick/idna-1.holger.znippy
curl -fsS -X PUT -H "Authorization: Bearer $(cat pki/adm.token)" --data-binary @stick/idna-1.holger.znippy "http://$HQ/bundles/idna-1.holger.znippy"
The downstream pulls the bundle, verifies it (the release signature, the approval, every payload hash, the server's signed scan evidence) and lands the wheel. pip installs it from the downstream:
until curl -fsS "http://$DS/production/simple/idna/" > /dev/null 2>&1; do sleep 1; done
pip install --no-cache-dir --index-url "http://$DS/production/simple/" idna==3.10
The server's Sync board names the downstream and what it applied: curl -fsS -H "Authorization: Bearer $(cat pki/aud.token)" "http://$HQ/-/sync/board".
5. The airgap: a bundle on a stick
A downstream with no network takes the same signed bundle from a stick. Verify it with the command-line tool (offline), then import it into an airgapped downstream: the plain one-way holger-downstream with network: Offline (a dial is refused before a socket opens), its admin granting this first bundle. Install the one-way build beside the online one (it compiles in no way out at all):
holger-cli verify stick/idna-1.holger.znippy --policy pki/trust-policy.ron
cargo install --root "$G/air" holger-downstream
sed -e "s|$G/ds|$G/air|g" -e "s|$DS_GRPC|$AIR_GRPC|; s|$DS\"|$AIR\"|" -e '/^ boundary:/d; /^ sync:/d; /^ mirror:/,/ron_unproven/d' ds.ron | sed '1a\ network: Offline,' > air.ron
"$G/air/bin/holger-downstream" bundle allow-first-import --config air.ron --from-bundle stick/idna-1.holger.znippy --policy pki/trust-policy.ron
"$G/air/bin/holger-downstream" bundle import --config air.ron --bundle stick/idna-1.holger.znippy --policy pki/trust-policy.ron
"$G/air/bin/holger-downstream" start --config air.ron > air.log 2>&1 &
until curl -fsS "http://$AIR/healthz" > /dev/null 2>&1; do sleep 0.2; done
pip install --no-cache-dir --index-url "http://$AIR/production/simple/" --target air-site idna==3.10
Stop the four programs: kill %1 %2 %3 %4.
Speed, measured
Every number here is generated from the newest holperf run on real packages (holperf/results/, cargo xtask bench-readme); none is written by hand.
Measured by holperf on real packages: oden (32 threads, 499 GiB RAM), 2026-11-01, holger ad6d66db; load1 26.6 before the run, 26.6 to 56.1 during it. Every set was fetched by its real client from its real registry (holperf/sets/, cargo xtask bench-readme fetch).
| set | what | packages | files | MB |
| pip | wheels from pypi.org (pip download) | 70 | 70 | 127 |
| npm | tarballs from registry.npmjs.org (npm pack) | 40 | 40 | 35.9 |
| maven | jars and poms from Maven Central | 30 | 60 | 42.3 |
| helm | charts from their projects' repositories (helm pull) | 17 | 17 | 2.40 |
| apt | Debian trixie .debs (apt-get download) | 65 | 65 | 75.3 |
| dnf | Fedora 42 .rpms (dnf5 download) | 57 | 57 | 127 |
| oci | container images (podman save, OCI layout) | 8 | 8 | 413 |
Each row first shows what it costs to get there: open the store and answer the first index request after it (cold page cache; time and CPU), and the peak RSS of the bench process over that format's run (the server and its HTTP clients run in it, with every set's store open; the clients read each reply through one buffer and keep no body, so the memory is the server's, with the server's allocator settings). Every set must stay within 512 MiB, half the lean box's 1 GB (cargo xtask bench-readme check). Then the warm numbers: the index in process and over the HTTP door, and downloads over the HTTP door. plain is the format's own sealed store; chain is the estate box's intake chain (every package landed, scanned, approved, sealed, AVAILABLE) serving the same set.
| set | served by | open + first index (ms) | its CPU (ms) | peak RSS (MiB) | index p50 / p99 in process (µs) | index over HTTP, c=32 (req/s) | small downloads, c=32 (req/s, MB/s) | largest file, c=8 (MB/s) | load1 |
| pip | plain | 0.325 + 1.21 | 1.12 | 144 | 2.02 / 2.67 | 41 853 | 4 638 / 8 420 | 9 214 | 38.9 |
| npm | plain | 1.25 + 1.95 | 1.17 | 139 | 171 / 46 511 | 78 347 | 8 939 / 8 038 | 8 219 | 33.7 |
| maven | plain | 2.12 + 0.036 | 0.341 | 144 | 0.210 / 0.221 | 60 305 | 4 527 / 6 396 | 4 706 | 45.2 |
| helm | plain | 2.46 + 39.4 | 21.2 | 139 | 0.241 / 0.251 | 64 044 | 31 809 / 4 578 | 4 942 | 43.1 |
| apt | plain | 0.369 + 79.0 | 77.4 | 149 | 1.99 / 2.08 | 60 158 | 7 455 / 8 664 | 8 155 | 37.5 |
| dnf | plain | 0.368 + 103 | 98.3 | 157 | 2.77 / 2.92 | 56 535 | 4 275 / 9 578 | 6 752 | 37.1 |
| oci | plain | 0.009 + 0.007 | 0.018 | 180 | 3.90 / 4.69 | 38 860 | 591 / 5 444 | 3 680 | 47.7 |
| pip | chain | 0.021 + 28.1 | 5.21 | 191 | 1.30 / 1.61 | 36 440 | 2 513 / 3 949 | 7 515 | 56.1 |
| npm | chain | 0.024 + 25.6 | 5.75 | 177 | 2.65 / 5.65 | 58 757 | 9 135 / 8 217 | 8 666 | 48.8 |
| maven | chain | 0.017 + 24.1 | 4.44 | 175 | 2.43 / 3.03 | 65 918 | 5 673 / 8 005 | 7 835 | 44.9 |
| helm | chain | 0.026 + 26.6 | 5.69 | 166 | 2.52 / 3.15 | 71 105 | 32 485 / 4 677 | 2 763 | 42.9 |
| apt | chain | 0.029 + 46.8 | 10.7 | 177 | 4.50 / 4.94 | 54 846 | 6 081 / 7 045 | 7 671 | 43.1 |
| dnf | chain | 0.017 + 30.1 | 6.76 | 191 | 5.24 / 6.26 | 53 758 | 4 166 / 9 379 | 9 263 | 36.5 |
The chain held 304 AVAILABLE files: every set above but OCI, landed through the intake chain with the sandbox off (the scan worker's container is not what this measures). The intake policy refused 5, which the chain rows do not serve: distlib-0.3.9-py2.py3-none-any.whl, numpy-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl, pip-24.2-py3-none-any.whl, setuptools-75.1.0-py3-none-any.whl, virtualenv-20.26.6-py3-none-any.whl.
Documentation
docs/v2/README.md: what Holger is, install, the RON, the intake chain, pairing, the Build Thing, the supply chain, the estate, security and the reference. The earlier pages are in docs/v1/.
On gunnar.rs, pages below the top folder are shown to signed-in users only for now. Guests read the same file raw: https://www.gunnar.rs/api/repos/nordisk/holger/raw?path=docs/v2/README.md
Release provenance (SLSA Build L2; why not L3 is said there): docs/v2/07-build-thing.md, the bundles in attestations/ and the release key in slsa/.
Development: run git config core.hooksPath .githooks once per clone; the hooks keep this README (generated from .nornir/README.md) and the AI-context packs in sync.
Contributing, security and changes
- CONTRIBUTING.md: how to get the code, report a bug and propose a change.
- CLA.md: the Contributor License Agreement (version 1.1) that every outside contribution needs; accept it with one line in CLA-SIGNATURES.md.
- SECURITY.md: how to report a vulnerability privately (holger@holger.rs).
- CHANGELOG.md: what changed in each release, and the vulnerabilities each one fixed.
License
AGPL-3.0-only, © 2026 Vetra Automation AB and Ignalina ApS (Rickard Ernst Björn Lundin) (jointly owned in equal shares, 50/50). Commercial terms are available from both co-owners jointly; write to gunnar@gunnar.rs. Versions published to crates.io on or before 2026-10-02 keep the license they were published with (mostly MIT). See LICENSE, NOTICE, COMMERCIAL-LICENSING.md, TRADEMARKS.md and THIRD_PARTY_LICENSES.md.