Artifacts

Publishing NPM Packages to Azure Artifacts

Complete guide to publishing and consuming NPM packages using Azure Artifacts feeds with CI/CD integration

Publishing NPM Packages to Azure Artifacts

Overview

Azure Artifacts provides a private NPM registry that lives inside your Azure DevOps organization, letting you publish, version, and consume internal packages without exposing them to the public npmjs.com registry. If your team shares utility libraries, API clients, or configuration packages across multiple Node.js projects, Azure Artifacts eliminates the mess of copying code between repositories. This guide covers everything from creating your first feed to building a full CI/CD pipeline that automatically publishes versioned packages.

Prerequisites

Before you start, make sure you have the following in place:

  • An Azure DevOps organization and project (free tier works fine)
  • Node.js 16+ and npm 8+ installed locally
  • Basic familiarity with package.json and npm commands
  • An Azure DevOps Personal Access Token (PAT) with Packaging (Read & Write) scope
  • For CI/CD sections: an Azure Pipelines YAML pipeline in your repository

Setting Up an NPM Feed in Azure Artifacts

The first step is creating a feed. A feed is essentially a private package registry hosted within your Azure DevOps organization.

Navigate to your Azure DevOps project, click Artifacts in the left sidebar, and click Create Feed. You will see a dialog with several options:

  • Name: Give it something descriptive like my-org-npm or shared-packages
  • Visibility: Choose between project-scoped (only this project) or organization-scoped (all projects)
  • Upstream sources: Enable this to proxy packages from npmjs.com through your feed

I recommend enabling upstream sources. This means your feed acts as a transparent proxy for public npm packages, so your .npmrc only needs to point at one registry for both internal and public packages. It also caches public packages, which protects you if npmjs.com goes down or a package gets unpublished.

Once created, click Connect to feed and select npm. Azure DevOps generates the .npmrc configuration you need. Keep this page open because you will reference it throughout this guide.

Feed URLs

Every feed has two URLs you need to know:

# Registry URL (for npm config)
https://pkgs.dev.azure.com/{organization}/{project}/_packaging/{feed}/npm/registry/

# Publishing URL (used internally by npm publish)
https://pkgs.dev.azure.com/{organization}/{project}/_packaging/{feed}/npm/

If you created an organization-scoped feed, the {project} segment is omitted:

https://pkgs.dev.azure.com/{organization}/_packaging/{feed}/npm/registry/

Configuring .npmrc for Publishing

The .npmrc file tells npm where to publish and how to authenticate. You need two .npmrc files: one at the project level and one at the user level.

Project-Level .npmrc

Create a .npmrc file in your package root directory (next to package.json):

registry=https://pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/
always-auth=true

This file gets committed to your repository. It tells npm to use your Azure Artifacts feed as the default registry for installs and publishes.

User-Level .npmrc (Authentication)

The user-level .npmrc lives at ~/.npmrc and contains your credentials. Never commit this file.

; begin auth token
//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/:username=myorg
//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/:_password=BASE64_ENCODED_PAT
//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/:email=npm requires email but doesn't use it
//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/:username=myorg
//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/:_password=BASE64_ENCODED_PAT
//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/:email=npm requires email but doesn't use it
; end auth token

Notice you need auth entries for both the /registry/ path (for installs) and the base /npm/ path (for publishes). Missing either one is the number-one cause of 401 errors.

Generating the Base64 PAT

Your PAT must be base64-encoded in the .npmrc file. Here is how to encode it:

# On Linux/macOS
echo -n "YOUR_PAT_HERE" | base64

# On Windows (PowerShell)
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("YOUR_PAT_HERE"))
# Example output
bXlwYXRoZXJlMTIzNDU2Nzg5MA==

Use the output as your _password value.

Using vsts-npm-auth (Windows)

If you are on Windows, Microsoft provides a helper tool that manages authentication automatically:

npm install -g vsts-npm-auth

# Generate/refresh credentials
vsts-npm-auth -config .npmrc

This tool reads your project .npmrc, prompts for Azure DevOps login, and writes the credentials to your user-level .npmrc. It handles token refresh as well.

Scoped vs Unscoped Packages

You have two choices when naming your internal packages: scoped or unscoped.

Scoped Packages

Scoped packages use the @scope/package-name format. This is the approach I recommend for internal packages:

{
  "name": "@myorg/http-client",
  "version": "1.0.0"
}

Scopes let you route different package prefixes to different registries. You can keep @myorg/* pointing at Azure Artifacts while everything else resolves from npmjs.com:

# Project .npmrc with scope-specific registry
@myorg:registry=https://pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/

This means you do not need to set the default registry at all. Public packages resolve normally from npmjs.com, and only @myorg/* packages go through Azure Artifacts.

Unscoped Packages

Unscoped packages use a plain name like http-client. This works fine but requires you to set the default registry to your Azure Artifacts feed, which means all installs (including public packages) route through your feed. You need upstream sources enabled for this to work.

{
  "name": "http-client",
  "version": "1.0.0"
}

My recommendation: use scoped packages. They make it obvious which packages are internal, they avoid name collisions with public packages, and they give you more flexibility with registry routing.

Authenticating in Different Contexts

Personal Access Tokens (PATs)

For local development, PATs are the simplest option. Create one in Azure DevOps under User Settings > Personal Access Tokens with the Packaging (Read & Write) scope.

PATs expire. Set a reminder to rotate them. A 90-day expiration is a reasonable balance between security and convenience.

Service Connections (CI/CD)

For pipelines, you should not use PATs. Instead, use the built-in pipeline identity or a service connection. Azure Pipelines has a npmAuthenticate task that handles this:

steps:
  - task: npmAuthenticate@0
    inputs:
      workingFile: .npmrc

This task injects a temporary token into the .npmrc file that is valid only for the duration of the pipeline run. No secrets to manage, no tokens to rotate.

Programmatic Authentication with Node.js

If you need to authenticate programmatically (for example, in a build script), you can set the token via environment variable:

var execSync = require("child_process").execSync;

var registryUrl = "//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/";
var token = process.env.AZURE_ARTIFACTS_TOKEN;

execSync("npm config set " + registryUrl + ":_authToken " + token, {
  stdio: "inherit"
});

Publishing Your First Package

Let us walk through publishing a real package. We will create a simple utility library.

Package Structure

my-utils/
  ├── package.json
  ├── .npmrc
  ├── .npmignore
  ├── index.js
  ├── lib/
  │   ├── retry.js
  │   └── logger.js
  └── test/
      └── retry.test.js

package.json

{
  "name": "@myorg/my-utils",
  "version": "1.0.0",
  "description": "Shared utility functions for myorg services",
  "main": "index.js",
  "files": [
    "index.js",
    "lib/"
  ],
  "scripts": {
    "test": "mocha test/**/*.test.js",
    "prepublishOnly": "npm test"
  },
  "repository": {
    "type": "git",
    "url": "https://dev.azure.com/myorg/myproject/_git/my-utils"
  },
  "author": "MyOrg Engineering",
  "license": "UNLICENSED"
}

index.js

var retry = require("./lib/retry");
var logger = require("./lib/logger");

module.exports = {
  retry: retry,
  logger: logger
};

lib/retry.js

/**
 * Retry an async operation with exponential backoff.
 * @param {Function} fn - Async function to retry
 * @param {Object} opts - Options
 * @param {number} opts.retries - Max retry attempts (default 3)
 * @param {number} opts.baseDelay - Base delay in ms (default 1000)
 * @returns {Promise} Result of fn
 */
function retry(fn, opts) {
  var retries = (opts && opts.retries) || 3;
  var baseDelay = (opts && opts.baseDelay) || 1000;

  return new Promise(function (resolve, reject) {
    var attempt = 0;

    function execute() {
      attempt++;
      fn()
        .then(resolve)
        .catch(function (err) {
          if (attempt >= retries) {
            reject(err);
            return;
          }
          var delay = baseDelay * Math.pow(2, attempt - 1);
          console.log(
            "Attempt " + attempt + " failed, retrying in " + delay + "ms..."
          );
          setTimeout(execute, delay);
        });
    }

    execute();
  });
}

module.exports = retry;

.npmrc (project-level)

@myorg:registry=https://pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/
always-auth=true

.npmignore

test/
.azure-pipelines/
*.test.js
.env

Publishing

With your user-level .npmrc configured with credentials:

# Dry run first to see what gets published
npm publish --dry-run

# Publish for real
npm publish
npm notice
npm notice 📦  @myorg/[email protected]
npm notice === Tarball Contents ===
npm notice 198B index.js
npm notice 856B lib/retry.js
npm notice 423B lib/logger.js
npm notice 512B package.json
npm notice === Tarball Details ===
npm notice name:          @myorg/my-utils
npm notice version:       1.0.0
npm notice filename:      myorg-my-utils-1.0.0.tgz
npm notice package size:  1.2 kB
npm notice unpacked size: 1.9 kB
npm notice total files:   4
+ @myorg/[email protected]

After publishing, the package appears in your Azure Artifacts feed. You can browse it in the Azure DevOps web UI under Artifacts.

Consuming Packages from the Feed

On the consumer side, any project that needs your package just needs the project-level .npmrc and authentication configured:

# Consumer project .npmrc
@myorg:registry=https://pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/
always-auth=true

Then install normally:

npm install @myorg/my-utils
var utils = require("@myorg/my-utils");

// Use the retry utility
utils.retry(function () {
  return fetchDataFromApi();
}, { retries: 5, baseDelay: 2000 })
  .then(function (data) {
    console.log("Got data:", data);
  })
  .catch(function (err) {
    console.error("All retries failed:", err.message);
  });

Using Upstream Sources

If you enabled upstream sources on your feed, public packages from npmjs.com are cached automatically. The first time someone installs express through your feed, Azure Artifacts fetches it from npmjs.com and caches it. Subsequent installs pull from the cache.

This gives you several benefits:

  • Faster installs (packages served from Azure's CDN)
  • Protection against left-pad style unpublishing incidents
  • Audit trail of which public packages your organization uses
  • Ability to block specific public package versions

Version Management Strategies

Version management is where teams usually get sloppy. Here are approaches that actually work.

Semantic Versioning (Manual)

The simplest approach. Bump the version in package.json manually before publishing:

# Patch release (bug fixes): 1.0.0 -> 1.0.1
npm version patch

# Minor release (new features): 1.0.0 -> 1.1.0
npm version minor

# Major release (breaking changes): 1.0.0 -> 2.0.0
npm version major

The npm version command updates package.json, creates a git commit, and tags the commit. It is better than editing package.json by hand because it keeps your git history clean.

Automated Versioning in CI/CD

For automated pipelines, you can generate version numbers from the build:

// scripts/set-version.js
var fs = require("fs");
var path = require("path");

var packagePath = path.join(__dirname, "..", "package.json");
var pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));

var buildNumber = process.env.BUILD_BUILDNUMBER || "0";
var parts = pkg.version.split(".");
var major = parts[0];
var minor = parts[1];

// Use build number as patch version
pkg.version = major + "." + minor + "." + buildNumber;

fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + "\n");
console.log("Version set to " + pkg.version);

Prerelease Versions

For packages that are not ready for production, use prerelease tags:

# Publish a beta
npm version 2.0.0-beta.1
npm publish --tag beta

# Consumers install the beta explicitly
npm install @myorg/my-utils@beta

# The "latest" tag still points to the last stable release
npm install @myorg/my-utils
# ^^ installs 1.x.x, not 2.0.0-beta.1

This is critical. By default, npm publish tags the release as latest. If you publish a prerelease without --tag, every consumer running npm install or npm update gets your untested beta. Always use --tag for prereleases.

View Tags on Your Feed

You can list the available dist-tags for a package:

npm dist-tag ls @myorg/my-utils
latest: 1.2.3
beta: 2.0.0-beta.4
next: 2.0.0-rc.1

Access Control and Permissions

Azure Artifacts uses a role-based permission model with four roles:

Role Read Publish Manage
Reader Yes No No
Collaborator Yes Yes (new packages) No
Contributor Yes Yes (all) No
Owner Yes Yes Yes

Feed-Level Permissions

Go to your feed settings and click Permissions to assign roles. Key points:

  • The Project Collection Build Service account needs Contributor to publish from pipelines
  • Project Collection Valid Users should have at least Reader for organization-wide feeds
  • External guests can be granted access through Azure AD B2B

Restricting Who Can Publish

If you want only certain people or pipelines to publish, remove the broad Contributor role and assign it specifically:

  1. Remove Contributors group from feed permissions
  2. Add specific users or service accounts as Contributor
  3. Keep the Readers group for everyone who consumes packages

Package-Level Permissions

Azure Artifacts also supports package-level permissions. You can restrict who can push new versions of a specific package:

  1. Click on the package in the Artifacts UI
  2. Click the gear icon for package settings
  3. Under Permissions, add specific users or groups

This is useful when you have a shared feed but want to ensure only the API team can publish @myorg/api-client and only the infra team can publish @myorg/terraform-utils.

Complete Working Example: CI/CD Pipeline

Here is a full Azure Pipelines YAML file that builds, tests, and publishes an npm package to Azure Artifacts with proper versioning.

azure-pipelines.yml

trigger:
  branches:
    include:
      - main
  paths:
    exclude:
      - '*.md'
      - 'docs/**'

pr:
  branches:
    include:
      - main

pool:
  vmImage: 'ubuntu-latest'

variables:
  feedName: 'shared-packages'
  isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]

stages:
  - stage: Build
    displayName: 'Build & Test'
    jobs:
      - job: BuildTest
        displayName: 'Build and Test'
        steps:
          - task: NodeTool@0
            displayName: 'Use Node.js 20.x'
            inputs:
              versionSpec: '20.x'

          - task: npmAuthenticate@0
            displayName: 'Authenticate npm'
            inputs:
              workingFile: .npmrc

          - script: npm ci
            displayName: 'Install dependencies'

          - script: npm run lint
            displayName: 'Run linter'
            continueOnError: false

          - script: npm test
            displayName: 'Run tests'

          - script: npm audit --audit-level=high
            displayName: 'Security audit'
            continueOnError: true

  - stage: Publish
    displayName: 'Publish Package'
    dependsOn: Build
    condition: and(succeeded(), eq(variables.isMain, true))
    jobs:
      - job: PublishPackage
        displayName: 'Publish to Azure Artifacts'
        steps:
          - task: NodeTool@0
            displayName: 'Use Node.js 20.x'
            inputs:
              versionSpec: '20.x'

          - task: npmAuthenticate@0
            displayName: 'Authenticate npm'
            inputs:
              workingFile: .npmrc

          - script: npm ci
            displayName: 'Install dependencies'

          - script: |
              node -e "
              var fs = require('fs');
              var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
              var parts = pkg.version.split('.');
              pkg.version = parts[0] + '.' + parts[1] + '.' + '$(Build.BuildId)';
              fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
              console.log('Publishing version: ' + pkg.version);
              "
            displayName: 'Set version from build ID'

          - script: npm publish
            displayName: 'Publish to feed'

          - script: |
              var pkg = require('./package.json');
              console.log('##vso[build.updatebuildnumber]' + pkg.name + '@' + pkg.version);
            displayName: 'Update build number'

Pipeline for Prerelease Branches

If you want feature branches to publish prerelease versions:

  - stage: PublishPrerelease
    displayName: 'Publish Prerelease'
    dependsOn: Build
    condition: and(succeeded(), ne(variables.isMain, true), eq(variables['Build.Reason'], 'PullRequest'))
    jobs:
      - job: PublishBeta
        displayName: 'Publish Beta to Azure Artifacts'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '20.x'

          - task: npmAuthenticate@0
            inputs:
              workingFile: .npmrc

          - script: npm ci
            displayName: 'Install dependencies'

          - script: |
              node -e "
              var fs = require('fs');
              var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
              var branch = '$(System.PullRequest.SourceBranch)'.replace('refs/heads/', '').replace(/[^a-zA-Z0-9]/g, '-');
              pkg.version = pkg.version.split('-')[0] + '-beta.' + branch + '.' + '$(Build.BuildId)';
              fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
              console.log('Publishing prerelease: ' + pkg.version);
              "
            displayName: 'Set prerelease version'

          - script: npm publish --tag beta
            displayName: 'Publish beta to feed'

Build Validation Script

Here is a script you can use locally to simulate what the pipeline does:

// scripts/validate-publish.js
var execSync = require("child_process").execSync;
var fs = require("fs");

function run(cmd) {
  console.log(">>> " + cmd);
  execSync(cmd, { stdio: "inherit" });
}

function main() {
  var pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
  console.log("Package: " + pkg.name + "@" + pkg.version);

  // Check if version already exists in the feed
  try {
    var result = execSync(
      "npm view " + pkg.name + "@" + pkg.version + " version",
      { encoding: "utf8" }
    ).trim();
    if (result === pkg.version) {
      console.error("ERROR: Version " + pkg.version + " already exists in the feed.");
      console.error("Bump the version before publishing.");
      process.exit(1);
    }
  } catch (err) {
    // Version doesn't exist yet, which is what we want
    console.log("Version " + pkg.version + " is available.");
  }

  // Run tests
  run("npm test");

  // Dry run publish
  run("npm publish --dry-run");

  console.log("\nValidation passed. Ready to publish.");
}

main();
node scripts/validate-publish.js
Package: @myorg/[email protected]
Version 1.3.0 is available.
>>> npm test

  retry
    ✓ should resolve on first attempt
    ✓ should retry on failure
    ✓ should reject after max retries
    ✓ should use exponential backoff

  4 passing (128ms)

>>> npm publish --dry-run
npm notice
npm notice @myorg/[email protected]
npm notice === Tarball Contents ===
npm notice 198B index.js
npm notice 856B lib/retry.js
npm notice 423B lib/logger.js
npm notice 512B package.json
npm notice === Tarball Details ===
npm notice name:          @myorg/my-utils
npm notice version:       1.3.0

Validation passed. Ready to publish.

Common Issues and Troubleshooting

1. 401 Unauthorized on npm publish

npm ERR! code E401
npm ERR! Unable to authenticate, your authentication token seems to be invalid.
npm ERR! To correct this please try logging in again with:
npm ERR!     npm login

Cause: Your .npmrc is missing the auth entry for the publish URL (the one without /registry/ at the end). You need auth entries for both paths.

Fix: Make sure your user-level ~/.npmrc has credentials for both the registry URL and the base npm URL:

//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/:_password=...
//pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/:_password=...

2. 403 Forbidden - No Matching Version

npm ERR! code E403
npm ERR! 403 Forbidden - PUT https://pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/@myorg%2fmy-utils
npm ERR! Cannot publish over the previously published version "1.2.0".

Cause: You are trying to publish a version that already exists. Azure Artifacts does not allow overwriting published versions (this is by design and is actually a good thing).

Fix: Bump your version number:

npm version patch
npm publish

3. 409 Conflict with Upstream Source

npm ERR! code E409
npm ERR! 409 Conflict - Package '@myorg/my-utils' conflicts with upstream source 'npmjs'

Cause: Your package name or scope conflicts with a package in the upstream npmjs.com source. This happens when you use an unscoped name that exists publicly.

Fix: Use a scoped package name with a scope unique to your organization. If the conflict is with the scope itself, you can configure your feed to block specific upstream packages in Feed Settings > Upstream Sources.

4. UNABLE_TO_GET_ISSUER_CERT_LOCALLY

npm ERR! code UNABLE_TO_GET_ISSUER_CERT_LOCALLY
npm ERR! unable to get local issuer certificate

Cause: Your corporate network uses a TLS-intercepting proxy (common in enterprise environments). The proxy's CA certificate is not trusted by Node.js.

Fix: Either add the corporate CA certificate or (for testing only) disable strict SSL:

# Proper fix: set the CA certificate
npm config set cafile /path/to/corporate-ca.crt

# Quick-and-dirty fix (NOT for production)
npm config set strict-ssl false

5. E404 When Installing from Feed

npm ERR! code E404
npm ERR! 404 Not Found - GET https://pkgs.dev.azure.com/myorg/myproject/_packaging/shared-packages/npm/registry/@myorg%2fmy-utils
npm ERR! 404 '@myorg/my-utils@^1.0.0' is not in this registry.

Cause: Either the package was never published, your .npmrc scope routing is wrong, or your credentials lack read access.

Fix: Verify the package exists in the feed (check the Artifacts UI). Confirm your .npmrc routes the scope to the correct feed URL. Ensure your PAT or service connection has at least Reader permission on the feed.

6. Pipeline Fails with "npmAuthenticate" Errors

##[error]Error: Unable to find .npmrc file at: /home/vsts/work/1/s/.npmrc

Cause: The npmAuthenticate task cannot find the .npmrc file. This happens if the file is not committed to the repo or the workingFile path is wrong.

Fix: Make sure the project-level .npmrc is committed to your repository (not in .gitignore) and the workingFile input matches its relative path.

Best Practices

  • Always use scoped packages for internal libraries. Scopes like @myorg/ make internal packages visually distinct and prevent name collisions with public packages. They also simplify .npmrc configuration since you can scope-route only your prefix.

  • Enable upstream sources and cache public packages. This protects against supply chain attacks, provides faster installs from the Azure CDN, and gives you an audit trail of every public dependency your organization consumes.

  • Never commit authentication tokens. Your project .npmrc should contain only the registry URL and always-auth=true. Tokens belong in the user-level ~/.npmrc for local development and in the npmAuthenticate task for pipelines.

  • Use npm ci instead of npm install in pipelines. The ci command deletes node_modules and installs from the lockfile exactly, producing deterministic builds. It is also faster because it skips the dependency resolution step.

  • Publish prerelease versions with explicit tags. Always use --tag beta or --tag next for non-production releases. If you forget and tag a prerelease as latest, fix it immediately with npm dist-tag add @myorg/[email protected] latest to point latest back to a stable version.

  • Set up retention policies on your feed. Azure Artifacts lets you configure how many versions to retain. Set a policy to keep the latest N versions and delete older ones automatically. This prevents your feed from growing indefinitely and keeps storage costs down.

  • Automate version bumping in CI/CD. Manual version management is error-prone. Use the build ID or a tool like semantic-release to generate versions automatically based on commit messages.

  • Lock down publish permissions. Not everyone on the team needs to publish. Grant Contributor only to service accounts and senior engineers. Everyone else gets Reader access. Publish through CI/CD, not from laptops.

  • Run npm audit in your pipeline. Azure Artifacts caches packages but does not block vulnerable versions by default. Add npm audit --audit-level=high as a pipeline step to catch known vulnerabilities before they ship.

  • Test package installation before releasing. Use npm pack to create the tarball locally, then install it in a consumer project with npm install ../myorg-my-utils-1.3.0.tgz. This catches missing files, broken main field paths, and other packaging issues before they hit the feed.

References

Powered by Contentful