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.


Scoped registry routing A scope-specific registry line routes packages under one scope to a private registry, while every other dependency continues to resolve from the public registry. Replacing the default registry instead would route everything through the private host. Scope the registry — do not replace it npm install one command, two destinations @acme/* → private registry authenticated, internal only everything else → registry.npmjs.org unauthenticated, cached, fast Replacing the default registry makes every dependency depend on your host's availability

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 .npmrc that CI did not read, so npm fell back to the default registry — and because the package had access: "public" (or an unscoped name), the publish succeeded rather than erroring.

Fix: Set publishConfig.registry in 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

Two-sided publish verification After publishing to a private registry, confirm the version exists there and separately confirm it does not exist on the public registry, which catches a misrouted publish. Check both sides — presence and absence on the intended registry npm view @acme/lib version --registry=https://npm.pkg.github.com expect: the version you just published on the public registry npm view @acme/lib version --registry=https://registry.npmjs.org expect: E404 — anything else is an incident
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 --mount=type=secret,id=npmrc,target=/root/.npmrc 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.

Where each setting lives Scope routing lives in the committed npmrc, the destination and access level live in publishConfig in the manifest, and the credential lives only in the CI secret store. Three homes, and only one of them holds a secret .npmrc (committed) scope → registry routing auth line referencing ${VAR} no literal token, ever publishConfig registry: the destination access: public or restricted travels with the package CI secret store the token itself injected as an env var rotated on a schedule

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



Back to CI/CD, Publishing & npm Provenance