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.
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.
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?
When you ask any CPAN client to install Some::Module, it has to
answer two separate questions:
MAINT/Amazon-API-2.6.0.tar.gz — chosen because its version
satisfies the constraint.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.
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.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 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?
--cascade-searchIf 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
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?
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.
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