Mirroring and Testing with a Local Verdaccio Registry
Run a throwaway local npm registry to rehearse publishes end to end: uplink proxying, publishing a real version, consumer install checks, and CI integration.
A packed tarball proves the files are right. It does not prove the release is right — that the version resolves through a caret range, that the dist-tag lands where intended, that a workspace publishes in dependency order, that a postinstall script behaves on a real install. Those failures show up in production releases:
npm error notarget No matching version found for @acme/core@workspace:^
A disposable local registry lets the whole publish happen for real, against a host you can delete afterwards.
Root Cause
The gap between “the tarball is correct” and “the release works” contains everything the registry does: version indexing, range resolution, dist-tag assignment, and metadata that consumers resolve against. None of that is exercised by installing a tarball by path, because a path install bypasses resolution entirely.
A local registry closes the gap by being a real registry. Verdaccio, the common choice, stores what you publish and proxies everything else to an uplink, so a consumer project pointed at it can install your package and its dependencies without the public registry knowing anything happened.
Minimal Reproduction
npm pack --silent
d=$(mktemp -d) && mv ./*.tgz "$d/" && cd "$d"
npm init -y >/dev/null
npm install ./*.tgz --silent
node -e "require('@acme/my-library')"
This passes. Then the real publish fails, because the manifest still contained a workspace:^ range that npm pack preserved verbatim and a path install never had to resolve.
What Each Rehearsal Covers
Step-by-Step Fix
1. Start a registry with an uplink to the public one
# verdaccio.config.yaml
storage: ./storage
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'@acme/*':
access: $all
publish: $anonymous # local only — never use this configuration elsewhere
'**':
access: $all
proxy: npmjs
logs: { type: stdout, format: pretty, level: warn }
npx verdaccio --config verdaccio.config.yaml --listen 4873 &
sleep 2 && curl -sf http://localhost:4873/-/ping && echo "registry up"
The '**' block with proxy: npmjs is what makes a full consumer install work — anything the local registry does not hold is fetched from the public one and cached.
2. Publish for real, against the local host
npm publish --registry=http://localhost:4873
npm notice publishing to http://localhost:4873
+ @acme/[email protected]
If this fails, it fails for the same reasons a real publish would: an unresolved workspace: range, a missing access setting, a prepublishOnly script that does not pass.
3. Install as a consumer would, through a range
d=$(mktemp -d); cd "$d"
npm init -y >/dev/null
printf 'registry=http://localhost:4873\n' > .npmrc
npm install "@acme/my-library@^1.4.0" --silent
npm ls @acme/my-library
node --input-type=module -e "const m = await import('@acme/my-library'); console.log('ok:', Object.keys(m).length)"
Expected result: the range resolves to 1.4.0, dependencies install through the proxy, and the import succeeds.
4. Wire it into CI as a release rehearsal
- name: Start local registry
run: |
npx verdaccio --config .github/verdaccio.yaml --listen 4873 &
npx wait-on http://localhost:4873/-/ping
- name: Publish to local registry
run: npm publish --registry=http://localhost:4873
- name: Install and smoke-test as a consumer
run: |
mkdir -p /tmp/consumer && cd /tmp/consumer
npm init -y
npm install "@acme/my-library@${{ github.ref_name }}" --registry=http://localhost:4873
node --input-type=module -e "await import('@acme/my-library')"
HAZARD PREVENTION
Symptom: The rehearsal passes, then the real publish fails with
EPUBLISHCONFLICT.Root cause: The version was already published to the real registry by an earlier run, and the rehearsal — being a different registry — had no knowledge of it.
Fix: Query the real registry for the version before publishing anywhere (
npm view "$PKG@$VERSION" version), and treat the rehearsal as a check on process rather than on version availability.
Verification
# the registry holds what you published
curl -sf http://localhost:4873/@acme%2fmy-library | node -e '
const d = JSON.parse(require("fs").readFileSync(0,"utf8"));
console.log("versions:", Object.keys(d.versions).join(", "), "| latest:", d["dist-tags"].latest);
'
# and the public registry knows nothing about it
npm view @acme/[email protected] version --registry=https://registry.npmjs.org 2>&1 | head -1
# teardown leaves nothing behind
kill %1 2>/dev/null; rm -rf ./storage && echo "registry stopped and storage removed"
Expected: the version listed locally, an E404 from the public registry, and a clean teardown.
Edge Cases / Gotchas
- Version immutability applies locally too. Republishing the same version fails until the storage directory is cleared, which is the correct behaviour and catches people mid-loop.
- A global registry setting is easy to leave behind. Configure the local registry in a temporary project’s
.npmrc, never withnpm config set registry, or every unrelated install on the machine goes through a stopped server. - The proxy caches aggressively. A dependency published upstream during your session may not appear until the cache is cleared, which occasionally looks like a resolution bug in your own package.
- Anonymous publish is a local-only convenience. The permissive configuration shown here is appropriate for a throwaway instance and dangerous anywhere reachable by other machines.
- Provenance cannot be rehearsed. A local registry does not implement attestation storage, so
--provenancefails against it; rehearse the rest and test provenance separately against the real registry.
Frequently Asked Questions
What does a local registry test that pack-and-install does not?
The publish itself and everything downstream: range resolution, dist-tag behaviour, workspace publish order, and lifecycle scripts on a real install.
Does a local registry need the real dependencies mirrored?
No. It proxies whatever it does not hold to a configured uplink, so a fresh instance serves the whole public registry while storing only what you publish.
Can the same version be published repeatedly during testing?
Not without clearing storage — immutability is enforced locally too. Delete the storage directory between runs, or publish a throwaway prerelease each time.
Is it safe to point the default registry at a local instance?
For a temporary test project, yes, and it is the point. Restrict it to that directory’s .npmrc rather than a global setting.
Does this work for a workspace with several packages?
Yes, and it is where the technique earns most — publishing every package in dependency order exercises the workspace protocol rewrite and the ordering, both invisible to a single-package rehearsal.
Related
- Publishing to Private and Alternative Registries — the registry choices this rehearses.
- Validating Packages Before Publish — the checks that run before the rehearsal is worth doing.
- Publishing Packages from a pnpm Workspace — the multi-package publish this technique validates end to end.