Credit: Generated by Google Gemini

Part I — the cracks in the mirrors

Have you ever pointed cpanm at your own mirror and found yourself flipping flags one at a time — --mirror, then --mirror-only, then --cascade-search — until the install finally caught, with no real idea why the combination that worked, worked? I have. And pretty much every time I stood up a new DarkPAN, despite twenty-plus years of experience with Perl.

So I’d do the thing you’re supposed to do. RTFM. Again and again. The levers are all documented, so what am I missing? Every lever has a label that tells me what it does — but no label tells me what it doesn’t do, or how it connects to the lever beside it.

Maybe how cpanm works is obvious to you. If you figured it all out without going down a rabbit hole — skip ahead to Part II, where I look at cpm and show how its resolver model gives us the hooks to make a DarkPAN work the way I expected all along. I’ll demonstrate how cpm creates the opportunity to fill in the cracks that prevented you and me from using a DarkPAN the way we really wanted.

On the other hand, if you, like me, went chasing the white rabbit, read on.

A short, fair history

It helps to know how we got here, because the tools in this story are not villains — each one solved the previous one’s obvious problem and quietly inherited its hidden one.

Once upon a time there was CPAN.pm and its cpan shell. It did resolve dependencies — it always has — but for a long stretch it did so unreliably: distributions shipped incomplete prerequisite metadata, dependency-following was config-dependent and sometimes interactive (it would stop and ask you), a failed test could wedge the whole chain, and the interactive shell made unattended installs fragile. A whole generation of Perl developers learned, through scar tissue, not to fully trust it — which is why “cpan doesn’t do dependencies” persists as folk memory even though it isn’t literally true. The lived experience was close enough.

Then in 2010 came cpanm (App::cpanminus), and it won almost overnight — deservedly. Its pitch wasn’t “we do dependencies and CPAN.pm doesn’t.” It was: we do them dependably and silently. Zero config, no prompts, follow prerequisites by default, fatpacked into a single script you could curl onto a bare box. It fixed the reliability problem so thoroughly that it became the default reflex for an entire community, and it earned that place.

But here’s the thing about a tool that’s good enough: it stops getting questioned. cpanm fixed the reliability of dependency resolution and inherited, unexamined, the model of resolution underneath it — a model built for a world where every “mirror” was a complete copy of canonical CPAN. That assumption didn’t rot from neglect. It ossified from success. cpanm was good enough that nobody needed to look underneath it — until they tried to run a DarkPAN.

The command that should work

Here’s where it starts. I host my own distributions on a DarkPAN — an S3-backed OrePAN2 repository — and I want to install one of them into a local library:

export PERL_CPANM_OPT="-n -v \
  --mirror https://cpan.openbedrock.net/orepan2 \
  --mirror https://cpan.metacpan.org"

cpanm -l $HOME Amazon-Lambda-Runtime-Builder-1.6.0.tar.gz 2>&1 | tee install.log

Read that environment variable the way I did. I’ve listed my DarkPAN as a mirror. The distribution I’m installing depends on a recent Amazon::API — a version I know is sitting on the openbedrock DarkPAN, because I put it there. So this should just work.

It doesn’t.

The version it wants is on a mirror I explicitly listed. cpanm even has an older copy in my PERL5LIB and knows it’s too old — that’s the whole complaint. And yet it won’t reach across to the mirror that has the version that would satisfy it. Something I told it about is being ignored.

The reflexive move — the one I made for longer than I’d like to admit — is to start flipping flags. But before we do that, I want to ask a question that turns out to explain everything, and that nothing in the cpanm documentation asks you: when cpanm installs a module, what are the two completely separate things it has to do?

Two jobs, not one

When you ask any CPAN client to install Some::Module, it has to answer two separate questions:

  1. Resolution. Which distribution, at which version, satisfies this request? The answer is a specific tarball — say, MAINT/Amazon-API-2.6.0.tar.gz — chosen because its version satisfies the constraint.
  2. Fetching. Where do I download that tarball from? The answer is a URL, or a list of them to try.

These feel like one motion when you’re sitting at the terminal, but they’re separate steps with separate machinery behind them, and the entire confusion we’re about to untangle comes from assuming a flag that touches one of them touches the other.

Here’s the claim it sets up, and I’ll spend the rest of this section defending: --mirror, on its own, only touches fetching. It does almost nothing to resolution. Which means the mirrors you so carefully listed have, by default, no say in the step that decides what version satisfies your dependency — the exact step that failed above.

If that sounds backwards, it’s because the word “mirror” is quietly lying to you. But we’ll talk about taxonomy in a moment.

The two metacpans

Look at that environment variable again. There is something hiding in plain sight:

--mirror https://cpan.metacpan.org

I put metacpan in my mirror list — last, as a fallback, so anything not on my DarkPANs could still come from public CPAN. Reasonable. But there are two completely different things called “metacpan” in play when cpanm runs, and conflating them is the single most expensive mistake in this whole story:

  • cpan.metacpan.org is a mirror — a URL, a place to download tarballs and read an index from. It’s a fetching source. It’s the one I typed.
  • The CPAN Meta DB is a resolver — an online service cpanm queries to turn “I need Amazon::API >= 2.6.0” into “that’s MAINT/Amazon-API-2.6.0.tar.gz.” It’s a resolution oracle. I did not type it. cpanm uses it anyway, by default, first, on every install.

Here is the part I eventually recognized: by default, cpanm resolves through the Meta DB, and your --mirror list has no part in resolution at all. When cpanm needed to satisfy Amazon::API >= 2.6.0, it did not consult 02packages.details.txt.gz on the openbedrock mirror. It asked the CPAN Meta DB — a service that indexes exactly one repository, public CPAN — “what’s the newest Amazon::API?” The Meta DB answered with the newest version it knows about, which is the public one, which is older than 2.6.0, because 2.6.0 only exists on my DarkPAN. Thus the constraint is not satisfied and the installation aborts.

The mirrors are not being ignored because of a bug; they simply are not part of the default resolution algorithm.

That’s the whole trap in one sentence: I configured the fetching step to prefer my DarkPAN, but that doesn’t make it part of the resolution step (by default). My “metacpan last” fallback was an afterthought - no more part of resolution than any other mirror in the list.

Nothing in --mirror’s documentation told me explicitly it has no part in resolution (by default). But it’s not the documentation’s fault. I made the erroneous assumption that cpanm consulted these configured mirrors to find the version I needed. Meta DB is the default and only participant in resolution in my configuration.

So maybe there is another flag to set, lever to pull, button to push?

The flag that turns the machine on by turning it “off”

The flag is --mirror-only, and its name is the second lie in this story.

Read it cold and it sounds like a restriction — “use only the mirrors, nothing else.” Which you might have assumed, as I did, that it aims to turn off any default mirrors that cpanm might be using. What it actually does is turn off the default resolution engine: the Meta DB lookup and turns on use of the mirror index. Without a default way to look up packages it now finally consults the mirror’s 02packages. Suddenly, Amazon::API@2.6.0 is installed.

cpanm --mirror-only -l $HOME Amazon-Lambda-Runtime-Builder-1.6.0.tar.gz 2>&1 | tee install.log

So, let’s recap:

  • --mirror lists repositories that host packages, indexed by 02packages.details.txt.gz which is not consulted by default.
  • --mirror-only tells cpanm to stop using Meta DB and BackPAN and use the mirror indexes for resolution. Full stop. So without putting cpan.metacpan.org at the end of my mirror list I would not be able to find and install modules not on my DarkPAN.

But there’s another lever we can pull, right?

A dead end named --cascade-search

If you go looking for how cpanm handles multiple mirrors — as I did, convinced there must be a “search all my mirrors for the right version” button — you will find one, and it’s a blind alley. That button is labeled --cascade-search, which entices you to push it and push it now! Unfortunately it does something very specific and does exactly nothing by default.

I reached for it first, without --mirror-only, reasoning that if the problem was cpanm not searching my mirrors, this was the feature that would force it to search my mirrors. It did nothing. The install failed exactly as before.

So, the author of cpanm added it as an option. It must do something? It does, it is a modifier on the mirror-index resolution walk which as we now know is only enabled when --mirror-only is also used.

Without --mirror-only, there is no mirror-index walk for it to modify because cpanm wasn’t consulting your mirror list, it was using Meta DB.

And what does it actually do, once you flip --mirror-only? We already know that cpanm walks your mirror lists and consults each 02packages so that can’t be what it enables. --cascade-search is for a different case: when a mirror has the module but at a version lower than you asked for, consult the next mirror in the list. Useful — but only if some earlier mirror carries a too-old copy that would otherwise shadow a good one further down. If your DarkPAN hosts only your own distributions and never a stale copy of something public, that case never arises, and you never need the flag at all!

 One flag adds a mirror
 And one flag skips them all
 And the one that just mocks you
 Don't do anything at all

When is an index not really an index?

Let’s step back and describe the landscape.

I host a package on my DarkPAN. Over time I push version 2.5.9, then 2.6.0, then 2.6.1. All three tarballs live happily in my S3-backed DarkPAN — none of them ever deleted, all of them one curl away.

But 02packages.details.txt.gz names only 2.6.1. It answers exactly one question — what is the latest version of Amazon::API? — and it knows nothing of 2.6.0 or 2.5.9, even though they’re sitting right there in the same bucket. An index that lists one of my three versions isn’t an index of what my repository contains. It’s an index of what’s newest, presented as though it were the whole story. That’s the first crack: the thing we call “the index” indexes current state, not contents.

So now the limitation of --mirror-only comes into focus. It resolves purely from 02packages.details.txt.gz, which means you will only ever get the latest version of a package. Pin a dependency to an older one and the install fails — not because the tarball is missing, but because the index you switched to can’t see past the present.

Which raises an obvious question. If 02packages is latest-only, how does cpanm CLI::Simple@2.0.1 install an old version? Because it does — try it.

The answer is that cpanm has two resolvers and switches between them silently, based on what you ask for. Ask for a bare module — cpanm CLI::Simple — and it resolves through the Meta DB, which is latest-only. Pin a version or a range — cpanm CLI::Simple@2.0.1, or ~"<= 2.0.1" — and since version 1.59_13 it quietly switches to a different service: MetaCPAN’s download_url API, which is history-aware and reaches back to BackPAN for the actual tarballs. You never see the switch. You just notice that pinning a version sometimes works.

A latest-only resolver — the Meta DB, or one reading 02packages.details.txt.gz directly — can’t give you history, which is why cpanm reaches for the MetaCPAN/BackPAN strategy when you pin a version. But every one of those resolvers queries public CPAN. None of them consults your DarkPAN. You created a neat little repository of your private packages that you can’t even reach! Unless…

…you flip the --mirror-only lever. The catch is that it also disables cpanm’s default strategy in favor of only walking a list of 02packages indexes. Which are, as we know, latest-only.

So there’s the trade, and it’s the whole knot: cpanm’s default path can resolve any historical version you name — but only from public CPAN. --mirror-only can resolve from your DarkPAN — but only the latest, even with cpan.metacpan.org pinned, because a mirror’s index is latest-only too. You can point cpanm at history, or you can point it at your repository. Not both.

Wouldn’t it be nice to stop choosing? Shouldn’t one setting resolve from public CPAN and your own repository, with the full history of both?

You say “mirror”, I say “repository”

With the introduction of tools that enable the creation of DarkPANs, the term “mirror” is sometimes interpreted to mean either a true mirror or a DarkPAN.

“Mirror” means a copy of the one true thing. A mirror of CPAN is, in the original sense, a full replica — the same tens of thousands of distributions, the same complete index, served from somewhere closer or faster. In that world the word is honest, and cpanm’s whole model makes sense: if every mirror is a complete copy of the same canonical CPAN, then of course a central oracle can resolve for all of them, and of course a “mirror” is just a storage bin — the resolution answer should hold on each listed mirror, so who cares which bin you pull from. This bad assumption from the top of the post, stated precisely: cpanm’s mirror model effectively treats every configured repository as isomorphic to canonical CPAN. And historically that may have been true.

A DarkPAN breaks that assumption completely, and the word never got the memo. My DarkPAN (and I suspect many others) is not a copy of CPAN. It’s a small set of distributions I host in addition to CPAN, some of them versions that will never exist on public CPAN at all. It is not a mirror in any meaningful sense. It’s a repository — an independent, authoritative source for its own contents, meant to be consulted alongside others, not as a replica of any of them. And I should be able to resolve package versions from it…but can’t with cpanm’s mirror-resolution model.

Every other packaging ecosystem figured this out and uses the honest word. apt has repositories, each shipping its own Packages. yum has repositories, each with its own repodata. Cargo has registries; pip has index URLs you stack with --extra-index-url. In none of them is there a central oracle that clients consult regardless of which sources you configured — the index that ships with each repository is that repository’s own truth, full stop, and the client reads them in the order you gave. Even in Perl, Pinto already speaks this language: you pinto init a repository, not a mirror.

Make that one substitution — stop saying “mirror,” start saying “repository” — and the solution to the cpanm problems becomes obvious. A repository is authoritative for its own contents. You have several. You want them consulted in an order you control, each answering (fully) for what it holds, with public CPAN as the last repository in the line rather than a secret oracle sitting in front of all of them. Said that way, the behavior you wanted from cpanm the whole time isn’t exotic or advanced. It’s just composition — an ordered list of authorities — and it’s the thing the word “mirror” was quietly preventing you from even asking for.

Bottom Line

So how do I have my cake and eat it too? I want to resolve dependencies from my DarkPAN and CPAN — with the full history of both.

cpanm can be coerced into getting close by setting --mirror-only, a full public repository pinned last (--mirror cpan.metacpan.org), and pulling the --cascade-search lever if any of your own repositories might shadow a public module with a stale copy. Three flags, each one trying to fill a gap that shouldn’t be there in the first place. And they work perfectly…

If and only if you want the latest version.

Stay tuned for Part II to see how cpm, a better repository index, and a small custom resolver can fix that.

A note on versions: this reflects cpanm/CPAN-client behavior as of late 2025 / early 2026. Resolver internals have shifted over the years — the two-resolver switch, for instance, arrived in 1.59_13 (2013) — so if you’re reading this later, verify against current docs before quoting me.


Next post: Reflecting on Mirrors - Part II

Previous post: Announcing Amazon::API 2.8.0 - A Lightweight AWS API for Perl