Publishing to Private and Alternative Registries
Publish scoped packages to GitHub Packages, an internal proxy, or a local registry: .npmrc scoping, CI auth, publishConfig, and keeping dual-format output correct.
Publishing somewhere other than the public npm registry changes very little about the package and quite a lot about the configuration around it. The dual-format build, the exports map, and the validation chain are identical; what changes is where the tarball goes, who is allowed to fetch it, and how credentials reach the publish command without ending up in a repository. This guide covers scoped registry configuration, publishConfig, CI authentication, and the verification that a publish landed where you intended — the last of which matters more here than anywhere else, because a misconfigured scope publishes an internal package to the public registry silently and irreversibly.
Prerequisites
Canonical Configuration Block
# .npmrc — committed, contains no secrets
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
always-auth=true
{
"name": "@acme/my-library",
"version": "1.4.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com",
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/acme/my-library.git"
}
}
Three details make this safe. The @acme:registry line scopes the routing, so every other dependency still comes from the public registry and stays cacheable. The auth line uses ${NODE_AUTH_TOKEN} — npm expands environment variables in .npmrc, so the committed file references a secret rather than containing one. And publishConfig.registry pins the destination in the manifest, so a publish from a machine with different ambient configuration still goes to the right place.
Step-by-Step Implementation
Step 1 — Scope the registry rather than replacing it
- registry=https://npm.pkg.github.com
+ @acme:registry=https://npm.pkg.github.com
The removed line routes every package through the alternative host. That is occasionally intended — an organisation-wide proxy — but as a default it makes every install depend on one internal service, loses the public registry’s cache locality, and turns an outage of your registry into an outage of every build in the company.
npm config get @acme:registry
npm config get registry
Expected output: the private URL for the scope, and https://registry.npmjs.org/ for the default.
Step 2 — Pin the destination in publishConfig
{
"publishConfig": {
"registry": "https://npm.pkg.github.com",
"access": "restricted"
}
}
Without this, the destination depends on whichever .npmrc happens to be in effect — the repository’s, the user’s home directory, or an environment variable — and those differ between a maintainer’s laptop and a CI runner. With it, the manifest states the intent and a mismatch fails loudly.
HAZARD PREVENTION
Symptom: An internal package appears on the public npm registry, publicly downloadable.
Root cause: The scope was configured in a
.npmrcthat CI did not read, so npm fell back to the default registry — and because the package hadaccess: "public"(or an unscoped name), the publish succeeded rather than erroring.Fix: Set
publishConfig.registryin the manifest so the destination travels with the package, and set"access": "restricted"so an accidental public publish fails instead of succeeding. Unpublishing is possible within 72 hours; assume the content has been mirrored regardless, and rotate anything sensitive it contained.
Step 3 — Supply credentials from the environment in CI
- uses: actions/setup-node@v4
with:
node-version: '22.11.0'
registry-url: 'https://npm.pkg.github.com'
scope: '@acme'
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
actions/setup-node writes a temporary .npmrc containing the auth line and reads the token from NODE_AUTH_TOKEN at publish time, which keeps the secret out of both the repository and the workflow log.
For registries without a dedicated action, write the file explicitly and delete it afterwards:
printf '@acme:registry=%s\n//%s/:_authToken=%s\n' \
"$REGISTRY_URL" "${REGISTRY_URL#https://}" "$REGISTRY_TOKEN" > .npmrc
trap 'rm -f .npmrc' EXIT
npm publish
Step 4 — Verify the publish landed where you intended
npm view @acme/my-library version --registry=https://npm.pkg.github.com
npm view @acme/my-library version --registry=https://registry.npmjs.org 2>&1 | head -1
1.4.0
npm error code E404
That pair of results is the only reliable confirmation. The publish command’s own success message reports that a registry accepted the tarball, not which one.
Step 5 — Rehearse a consumer install
d=$(mktemp -d); cd "$d"
npm init -y >/dev/null
printf '@acme:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$TOKEN" > .npmrc
npm install @acme/my-library --silent
node --input-type=module -e "const m = await import('@acme/my-library'); console.log('ok:', Object.keys(m).length, 'exports')"
A colleague’s first install is the real test of the configuration, and running it yourself once is cheaper than discovering the problem through them.
Tooling Validation
# what registry will this package publish to, from here?
npm config get @acme:registry
node -p "require('./package.json').publishConfig?.registry ?? '(not pinned)'"
# is authentication working, without publishing anything?
npm whoami --registry=https://npm.pkg.github.com
# does the tarball itself still pass the normal gates?
npx publint --strict && npx attw --pack .
# dry run, showing the destination
npm publish --dry-run 2>&1 | grep -E "registry|name|version"
Sample passing output:
https://npm.pkg.github.com
https://npm.pkg.github.com
acme-release-bot
All good!
npm notice publishing to https://npm.pkg.github.com
Compatibility Matrix
| Capability | npm registry | GitHub Packages | Verdaccio (self-hosted) | Artifactory / Nexus |
|---|---|---|---|---|
| Unscoped package names | Yes | No — scope must match the owner | Yes | Yes |
| Provenance attestations | Yes | No | No | No |
npm audit signatures |
Yes | No | No | Partial |
| Proxying the public registry | N/A | No | Yes | Yes |
| Granular access tokens | Yes | Repository-scoped tokens | Config-file users | Provider-specific |
latest and custom dist-tags |
Yes | Yes | Yes | Yes |
The provenance row is the one that most often decides the choice. Publishing to a registry with no attestation support means giving up the supply-chain link described in npm Provenance & Sigstore Attestation — acceptable for an internal package inside a controlled network, and a real loss for anything distributed more widely.
Choosing Where an Internal Package Should Live
Before configuring anything, it is worth being explicit about which of four situations you are in, because they have different right answers and are frequently conflated.
A genuinely private package for one organisation. Nobody outside should be able to install it. A private registry — GitHub Packages, an Artifactory instance, a self-hosted Verdaccio — is the correct home, and the access control is the point. The cost is that every consumer needs credentials configured, including CI runners and every developer’s laptop, and that onboarding friction is real.
A public package published from a private repository. The source is closed, the artefact is not. This belongs on the public registry, published from CI with a token scoped to that package. The repository being private only means provenance attestation cannot link to readable source, which reduces its value without making it harmful.
A package mirrored for availability. The upstream is public; you keep a copy so builds do not depend on an external service. This is a proxy configuration rather than a publishing one, and it should be transparent — consumers install the same names and versions, resolved through a caching layer.
A pre-release artefact for internal testing. A build that should be installable by a colleague but must never reach the public registry. The cleanest answer is usually a dist-tag on the public registry for a package that is public anyway, or a local registry for one that is not — not a private registry standing permanently alongside a public package, which doubles the publishing surface for a temporary need.
The distinction that matters most is between the first and the fourth, because they look identical from the command line and have opposite lifetimes. A private registry adopted for a temporary need tends to stay, and every package added to it inherits the credential-distribution problem permanently.
Credential Hygiene Across Machines
Registry authentication has one property that makes it unusually easy to get wrong: the same file, .npmrc, is read from several locations, and any of them can silently supply a token. In precedence order npm reads the project’s .npmrc, then the user’s ~/.npmrc, then a global file, then environment variables — and a stale token in the second of those will happily authenticate a publish you thought was going somewhere else.
Three rules keep this manageable.
Never commit a token, and make the mistake impossible rather than unlikely. The committed .npmrc should reference an environment variable; a token literal in that file is one git push away from being public. A repository-level secret scanner and a pre-commit hook rejecting _authToken= lines with anything other than a ${…} reference cost an hour to set up and remove the whole class of accident.
# .git/hooks/pre-commit — refuse literal tokens
if git diff --cached -U0 | grep -E '^\+.*_authToken=(?!\$\{)' -P >/dev/null; then
echo "refusing commit: literal auth token in .npmrc"
exit 1
fi
Scope tokens as narrowly as the registry allows. A token that can publish one package is a much smaller liability than one that can publish everything an account owns, and most registries now support per-package or per-repository scoping. Where expiry is available, use it — a 90-day token forces a rotation habit that an unexpiring one never will.
Audit what exists, on a schedule. Tokens accumulate: created for a migration, used once, never revoked. Listing them periodically and deleting anything without a current owner is the only way the list stays short.
npm token list # public registry
gh api /user/packages --paginate # GitHub Packages, for comparison
The deeper point is that credential distribution is the real cost of a private registry, and it is paid continuously rather than once. Every new developer, every CI pipeline, every containerised build needs the token, and every one of those is a place it can leak. That cost is worth paying for genuinely private code and is pure overhead for code that could have been public — which is why the four-way distinction above is worth making explicitly before the infrastructure exists.
What Consumers Have to Do Differently
A package on the public registry costs a consumer one command. A package on a private registry costs them configuration, and the friction lands on people who did not choose the registry. Documenting exactly what they need — in the README, not in a wiki nobody finds — is the difference between a package that gets adopted internally and one that generates a support thread per team.
The minimum a consumer needs is three lines and a token:
# .npmrc in the consuming project
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
always-auth=true
Three follow-on consequences are worth stating explicitly in the documentation, because each produces a predictable question.
CI needs the same token. Every pipeline that installs the package needs the secret configured, which means the token’s lifetime and rotation schedule are now other teams’ problem too. Publishing the rotation date somewhere visible prevents the failure mode where a token expires and six pipelines break simultaneously with an authentication error nobody recognises.
Containerised builds need it at the right layer. A Dockerfile that copies .npmrc into the image bakes a credential into a layer that may be pushed to a registry. The correct pattern is a build secret mounted for the install step only:
RUN npm ci
Lockfiles record the registry URL. A consumer who installs your package while pointed at a mirror gets that mirror’s URL written into their lockfile, and a colleague without access to it sees resolution failures for a package they can otherwise reach. Agreeing on one canonical URL per registry across an organisation avoids a class of confusing lockfile churn.
Migrating Between Registries
Packages move: an internal library becomes public, an organisation consolidates onto one host, a registry is retired. The mechanics are straightforward and the coordination is not, because consumers resolve through configuration you do not control.
The sequence that avoids breakage has four steps and one rule.
Publish to both for a transition period. The same version, the same tarball, two destinations. npm publish --registry=… overrides publishConfig for a single invocation, so the release script can push twice without changing the manifest.
npm publish --registry=https://npm.pkg.github.com
npm publish --registry=https://registry.npmjs.org --access public
Announce the new location with a date. Consumers need a window in which both work, and a specific day on which the old one stops. Vague deprecations are ignored; dated ones get calendar entries.
Deprecate on the old registry rather than unpublishing. npm deprecate prints a message at install time, which is exactly the channel that reaches someone who has not read the announcement:
npm deprecate "@acme/my-library@*" \
"Moved to the public registry — remove the @acme registry line from .npmrc" \
--registry=https://npm.pkg.github.com
Keep the version numbers continuous. Restarting at 1.0.0 on the new registry breaks every consumer’s range and makes the two histories impossible to reconcile. Continue from wherever the old registry left off, even if that means the first version on the new host is 4.2.7.
The rule underlying all four: a registry move is a change to how consumers reach the package, never to the package itself. The moment the artefacts differ between the two destinations — a rebuild, a different version, a changed dependency range — the migration stops being reversible and any consumer stuck mid-move is stuck for good. Publish identical bytes to both, and the transition remains something you can abandon halfway if it turns out to be a bad idea.
The Configuration a Release Depends On
Four settings decide where a package lands and who can fetch it, and they live in three different places — which is exactly why a publish can go somewhere nobody intended.
When a publish lands somewhere unexpected, the cause is almost always that one of the first two disagreed with an ambient .npmrc the runner also read — which is the argument for pinning the destination in the manifest.
Guides in This Section
- Publishing Scoped Packages to GitHub Packages — the scope-must-match-owner rule and the workflow permissions it needs.
- Configuring .npmrc Auth Tokens in CI — getting credentials to the publish command without committing them.
- Using publishConfig for Registry and Access — pinning destination and visibility in the manifest itself.
- Mirroring and Testing with a Local Verdaccio Registry — rehearsing a full publish-and-install cycle without touching a real registry.
Related
- Automating npm Releases with GitHub Actions — the pipeline these registry settings plug into.
- npm Provenance & Sigstore Attestation — what you keep or lose depending on the registry.
- Validating Packages Before Publish — the checks that run identically regardless of destination.
- Monorepo & Workspace Publishing — publishing several packages, which multiplies every configuration decision here.