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.jsonand 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-npmorshared-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-padstyle 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:
- Remove Contributors group from feed permissions
- Add specific users or service accounts as Contributor
- 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:
- Click on the package in the Artifacts UI
- Click the gear icon for package settings
- 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.npmrcconfiguration 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
.npmrcshould contain only the registry URL andalways-auth=true. Tokens belong in the user-level~/.npmrcfor local development and in thenpmAuthenticatetask for pipelines.Use
npm ciinstead ofnpm installin pipelines. Thecicommand deletesnode_modulesand 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 betaor--tag nextfor non-production releases. If you forget and tag a prerelease aslatest, fix it immediately withnpm dist-tag add @myorg/[email protected] latestto pointlatestback 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-releaseto 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 auditin your pipeline. Azure Artifacts caches packages but does not block vulnerable versions by default. Addnpm audit --audit-level=highas a pipeline step to catch known vulnerabilities before they ship.Test package installation before releasing. Use
npm packto create the tarball locally, then install it in a consumer project withnpm install ../myorg-my-utils-1.3.0.tgz. This catches missing files, brokenmainfield paths, and other packaging issues before they hit the feed.