Skip to content

Publish a private npm package

Publish a scoped package with npm publish and a limited publisher key. Then confirm the version and give read-only access with a download key.

Overview

This guide shows how to publish a private npm package to PrivateNPM with npm publish and a publisher key. You configure the project, create a limited publisher key, publish the first version and confirm the result. Then you give read-only access to a recipient with a download key.

The guide is for package maintainers and release engineers. The examples use the shared registry at registry.privatenpm.com, the scope @acme and the package @acme/sdk. A complete TypeScript example with pnpm follows the npm procedures.

Before you start

Make sure that these conditions are true:

  • Your organization has a scope on the shared registry, or a private registry. See the registry guide.
  • Your role has the npm:package:create and npm:package:publish permissions. The built-in Member and Admin roles have both.
  • To create a publisher key, your role must have registry:credential:manage. The built-in Admin role has it.
  • The package.json name is in your scope, and the registry does not have its version.
  • npm 11 and Node.js 24 are on the computer or the CI runner.

During a trial without a payment method, an organization can publish up to 3 packages with 10 GB of stored content. After the trial ends, a publish needs an active plan. Each plan has its own package limit. See the plans.

How it works

The publish request

The npm publish command packs the package directory into a gzip tarball. It calculates the SHA-512 integrity value and the SHA-1 shasum of the tarball. Then it sends one HTTP PUT request to the package URL, for example https://registry.privatenpm.com/@acme%2fsdk.

The body of the request is a JSON document. The document contains the manifest of the new version, the dist-tags and the tarball as a base64 attachment. The Authorization: Bearer header carries the publisher key.

The registry examines the request before it stores anything. The body must contain one version and one attachment, and the attachment name must match the package and the version. The bytes of the tarball must match the integrity value in the manifest. The key must have the Publish action, and its member must have the publish permission now. On the shared registry, your organization must own the scope of the name.

The registry then reserves the version and stores the tarball. One database transaction records the version, the storage reference, the dist-tags and the audit entry. Thus a version is complete or absent, never partly published. The registry answers with HTTP 201.

If the network fails after the upload, run the same publish again. The registry identifies the same content and returns the recorded result. If two publishes of one version arrive at the same time, one succeeds and the registry returns HTTP 409 for the other.

Versions are immutable

After you publish a version, its content cannot change. To change the code, publish a new version. If you unpublish a version, installs of that version stop. The version number stays as a tombstone, and the registry never accepts that number again.

If you delete a package, installs of every version stop. The registry does not accept the package name again in that registry. Thus a recipient never receives different bytes for a name and version that worked before.

Dist-tags

A dist-tag is a name that points to one version. The command npm install @acme/sdk installs the version of the latest tag. By default, npm publish moves latest to the new version. With --tag next, the publish moves only the next tag. Later changes with npm dist-tag need the Write dist-tags action on the key.

Which registry receives the publish

npm uses publishConfig.registry from package.json when it is present. Else npm uses the route of the package scope, and else the default registry. The credential line for that host must be in the configuration.

Configure the project

Before you start, you must know the registry URL. For a private registry, use its active custom domain.

  1. In package.json, set name to a name in your scope, for example @acme/sdk.
  2. Set version to a version that the registry does not have, for example 1.0.0.
  3. Create a project .npmrc with the scope route and a credential reference:
    @acme:registry=https://registry.privatenpm.com/
    //registry.privatenpm.com/:_authToken=${ACME_PUBLISHER_KEY}
  4. Commit the .npmrc file. The file contains no secret.

The scope route keeps public dependencies on npmjs.org. For an unscoped package on a private registry, use registry=https://npm.acme.com/ in the project .npmrc. That line also sends public dependencies to the private registry. The .npmrc guide explains each form.

Create a publisher key

  1. Open Registry → Registries.
  2. On the registry card, click Manage keys. The page shows Publisher keys and Download keys.
  3. Click New publisher key. The Create publisher key form opens.
  4. In Name, type the name of the computer or the CI release pipeline.
  5. Under Publisher permissions, select Publish and Read metadata. Clear the actions that the release does not need.
  6. Under Package access, clear All packages.
  7. In Restrict to a scope or Restrict to a package, select the scope or the package.
  8. Optional: in Expires, select the last day of use.
  9. Click Create publisher key. The page shows the secret one time.
  10. Copy the secret into your secret store as ACME_PUBLISHER_KEY.

For the first version of a new package, restrict the key to the scope. The package does not exist before the first publish. The npm dist-tag ls check in this guide needs Read metadata. A publisher key acts with the current permissions of the member who created it.

WARNING: Do not commit the secret of a publisher key. Anyone who has the secret can publish versions of your packages.

NOTE: The dashboard does not show a publisher key again. If you lose the secret, rotate the key. The rotation gives a new secret.

Publish the first version

Before you start, you must be in the package directory, with the publisher key in your secret store.

  1. Show the files that the tarball will contain:
    npm pack --dry-run
    npm lists the files and the size of the tarball.
  2. Make sure that the list contains no .env file, no .npmrc file and no other secret.
  3. Set the variable in the shell:
    export ACME_PUBLISHER_KEY=prt_...
  4. Publish the package:
    npm publish
    The output ends with lines similar to these:
    npm notice Publishing to https://registry.privatenpm.com/ with tag latest and default access
    + @acme/[email protected]

For a prerelease, publish with npm publish --tag next. Then latest continues to point to the stable version. Use the files field in package.json to keep source, tests and configuration out of the tarball.

Manage versions and dist-tags

These npm commands change the versions and tags of a published package:

npm dist-tag ls @acme/sdk
npm dist-tag add @acme/[email protected] latest
npm deprecate @acme/[email protected] "Use 1.1.0 or later"
npm unpublish @acme/[email protected]

The dist-tag and deprecate commands need the Write dist-tags action. A deprecated version stays installable, and npm shows the message at install time. The unpublish command needs the Unpublish action and the npm:package:delete permission. By default, only the built-in Admin role has that permission.

The package page under Registry → Packages has the same controls. The Versions section has Deprecate and Unpublish, and the Dist tags section shows each tag.

CAUTION: Do not unpublish a version that recipients use. Their installs of that version stop, and you cannot publish that version number again.

Example: publish a TypeScript package with pnpm

This example publishes a second package, @acme/greeting. The package exports one function and its TypeScript declarations. The example uses Node.js 24 and pnpm 12.5.1. Replace @acme with the scope that you reserved.

Before you start, create a new directory outside a current workspace. Create a src subdirectory in it. Then create the four files below.

package.json

{
  "name": "@acme/greeting",
  "version": "1.0.0",
  "type": "module",
  "license": "UNLICENSED",
  "packageManager": "[email protected]",
  "files": ["dist"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc --project tsconfig.json",
    "prepack": "pnpm run build"
  },
  "publishConfig": {
    "registry": "https://registry.privatenpm.com/"
  },
  "devDependencies": { "typescript": "5.9.3" }
}

The files field puts only the dist directory in the tarball. The prepack script compiles the source before each pack and each publish. The publishConfig.registry value sends the publish to PrivateNPM. For a private registry, use its active custom domain here and in .npmrc.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "rootDir": "src",
    "outDir": "dist",
    "noEmitOnError": true
  },
  "include": ["src/**/*.ts"]
}

The declaration option writes the .d.ts file that the types condition refers to. The noEmitOnError option stops the build when the source has a type error.

src/index.ts

export function greet(name: string): string {
  return `Hello, ${name}!`;
}

pnpm-workspace.yaml

minimumReleaseAge: 10080

This file keeps a dependency release-age policy of seven days (10080 minutes). If your team has a stricter policy, keep the stricter policy.

Build and examine the tarball

  1. Install the dependencies and create the lockfile:
    pnpm install --ignore-scripts
  2. Commit the pnpm-lock.yaml file.
  3. Install again from the lockfile, as later builds do:
    pnpm install --frozen-lockfile --ignore-scripts
  4. Pack the package:
    pnpm pack
    pnpm runs prepack and writes acme-greeting-1.0.0.tgz.
  5. Examine the tarball. It must contain dist/index.js and dist/index.d.ts, and no credentials.
  6. Add node_modules/, dist/ and *.tgz to .gitignore.

Publish with pnpm

pnpm 12 does not expand token environment variables in a project .npmrc. Keep only the scope route in that file:

@acme:registry=https://registry.privatenpm.com/

Set ACME_PUBLISHER_KEY from your secret store. Then give pnpm the credential through its host-scoped environment variable, and publish:

env "pnpm_config_//registry.privatenpm.com/:_authToken=$ACME_PUBLISHER_KEY" pnpm publish --tag latest

If the directory is a Git repository, pnpm publish also examines the Git state. The pnpm publish documentation explains those checks. After the publish, the package page shows version 1.0.0.

Install the example in a separate project

This test installs the built package from the registry, not from your source directory. Before you start, create a download key that grants only @acme/greeting. Set ACME_NPM_TOKEN to that key through your secret store.

  1. Create a consumer directory outside the directory of the package.
  2. In the consumer directory, create package.json with this content:
    {"name":"greeting-consumer","private":true,"type":"module","packageManager":"[email protected]"}
  3. Create a .npmrc that contains only the scope route:
    @acme:registry=https://registry.privatenpm.com/
  4. Copy pnpm-workspace.yaml from the package directory, so that the consumer uses the same release-age policy.
  5. Add the package with an empty store:
    env "pnpm_config_//registry.privatenpm.com/:_authToken=$ACME_NPM_TOKEN" pnpm --store-dir .pnpm-store add --save-exact --ignore-scripts @acme/[email protected]
  6. Install again from the new lockfile:
    env "pnpm_config_//registry.privatenpm.com/:_authToken=$ACME_NPM_TOKEN" pnpm --store-dir .pnpm-store install --frozen-lockfile --ignore-scripts
  7. Create index.mjs with this content:
    import { greet } from '@acme/greeting';
    console.log(greet('PrivateNPM'));
  8. Run the file:
    node index.mjs
    The output is Hello, PrivateNPM!.

The empty store in steps 5 and 6 makes sure that pnpm downloads the tarball from the registry. If the release-age policy refuses the new release, wait until the release is old enough. Do not make the policy weaker to complete the example. The pnpm settings documentation describes the minimumReleaseAgeExclude option for names that you trust.

For the next release, change the version to 1.0.1 and do the checks again.

Verify the result

Before you start, the publish must be complete. You need permission to create download keys.

  1. Open Registry → Packages and click the package. The Versions section shows 1.0.0.
  2. Examine the Dist tags section. The latest tag points to 1.0.0.
  3. Show the tags from the command line:
    npm dist-tag ls @acme/sdk
    npm prints latest: 1.0.0.
  4. On the Manage keys page, click New download key.
  5. Under Access rule, select Selected packages, and select the new package.
  6. Click Create download key. The page shows the secret of the download key.
  7. In a new directory, install the package with the download key. Use the .npmrc verification steps. npm installs 1.0.0.
  8. On the page of the test key, click Revoke key.
  9. Install again in a different new directory with an empty cache and the revoked key. npm returns E401.

A cached install does not test revocation, so use an empty cache in step 9. Copies that recipients downloaded before the revocation stay usable.

Troubleshoot a publish

The registry returns a message with each refused publish. npm shows the message after the status code.

SymptomCauseCorrection
E401npm sent no credential, or the registry did not accept the key.Follow the publish checks in the E401 guide.
E403: you cannot publish over a previous versionThe version exists, or it is a tombstone of an unpublished version.Increase the version in package.json and publish again.
E403: a deleted package had this nameSomeone deleted a package with this name in this registry.Use a different name.
E403: the shared registry requires a scoped nameThe name has no scope.Use your reserved scope, or publish to a private registry.
E403: the scope is not reservedYour organization does not own the scope on the shared registry.Reserve the scope under Registry → Registries.
E403: the key cannot publish this packageThe key has no Publish action, a different restriction, or a member without permission.Examine the key page and the role of its member.
E403: the plan does not allow more packagesThe organization reached the package limit of its plan or trial.Open Organization → Plan & usage and select a plan.
E403: a publish needs an active planThe trial ended.Select a plan under Organization → Plan & usage.
E400: invalid publish requestThe manifest, the name, the version or the tarball integrity is not valid.Correct package.json, pack again and publish again.
E409: conflictA different publish of the same version is in progress.Wait, then examine the versions on the package page before you publish again.
The error URL shows registry.npmjs.orgThe scope route or publishConfig.registry is absent.Add the route to the project .npmrc.

Security and maintenance

  • Use a separate publisher key for each release pipeline and each developer computer.
  • Restrict each publisher key to the scope or package that it releases.
  • Select only the actions that the release needs. A release usually needs Publish, and sometimes Write dist-tags.
  • Set an expiry date on keys for short projects or for contractors.
  • Rotate a key after a staff change or a possible leak. Use Rotate key under Registry → Registry keys.
  • After the first version, configure trusted publishing. Then CI publishes without a stored publisher key.
  • Keep one publisher key for releases from a computer when CI is not available.
  • Use download keys for installs. Do not give a publisher key to a recipient.
  • Examine each publish, unpublish and key change in Organization → Audit log.

The npm_package.published and npm_package.unpublished webhook events tell your systems about each release.

Next: Install in an app

Install the package in a clean project with a read-only download key.

Install in an app

You need an organization to do these steps. Start a 30-day trial. Before you add a card, you can publish 3 packages and store 10 GB of content.

READY WHEN YOU ARE

Put private packages to work.