Artifacts & dependencies
Feeds, pinning, dependency integrity.
Open the dependency file of any service you run in production and count what is actually yours. Most of the application is code other people wrote, often 80 to 90 percent of it by volume. That makes package management a security job rather than housekeeping. A hospital pharmacy solves the same problem: no drug reaches a patient except through one counter, and every batch arrives with a known supplier, an exact lot number, and a record of who dispensed it. Azure Artifacts is that counter for your builds. The word *artifact* covers two different things in Azure DevOps. There are the packages your code pulls in and publishes (npm for JavaScript, NuGet for .NET, Maven, Python, Cargo, Universal Packages), and there is the build artifact, the output your pipeline compiles once and then moves unchanged through every environment. Both want the pharmacy rules: one source, exact versions, no quiet substitutions.
Feeds: one door in for every package
A feed is a private, versioned package registry that lives inside Azure DevOps, scoped either to one project or to the whole organization. The setting that earns its keep is upstream sources. Instead of letting every build call npmjs.com or nuget.org directly, the feed stands in front of those public registries, and the first time anyone asks for a particular package version, the feed keeps a permanent copy. Every build after that gets *that* copy, even if the public package is later unpublished, deleted, or replaced with something malicious. Remember left-pad, the eleven-line npm package whose removal broke builds across the internet? A feed with upstreams turns that into a non-event. You also end up with a list of exactly which outside code has walked through the door.
Feeds also have views: @local, @prerelease, @release. A view is a promotion gate for the packages you publish yourself. A library lands in @local, gets promoted to @release only once it has passed validation, and each consuming team subscribes to whichever view matches the risk it is willing to take. Publishing and restoring always target the default @local view. The other two are suggestions you can rename or delete.
# .npmrc at repo root — ONE registry; public packages arrive via upstreamsregistry=https://pkgs.dev.azure.com/contoso/_packaging/platform/npm/registry/always-auth=true# Publish the internal library (version comes from package.json)$ npm publishnpm notice package: @contoso/[email protected]npm notice total files: 14, unpacked size: 87.3 kB+ @contoso/[email protected]# Consume — express is pulled through the upstream and cached in the feed$ npm install @contoso/logging expressadded 64 packages in 4s# Universal Packages: version arbitrary blobs (ML models, CLIs) with az$ az artifacts universal publish \--organization https://dev.azure.com/contoso \--feed platform --name fraud-model --version 3.4.1 --path ./model/# uploads via ArtifactTool; exit code 0 on success
Pipelines sign in as the build service identity, and Azure Artifacts gives that identity the Feed and Upstream Reader (Collaborator) role by default. That is enough to restore packages and to save new ones through an upstream, though *publishing* into the feed needs the bigger Feed Publisher (Contributor) role. When the feed lives in another organization, or when a script or task runner has to authenticate on its own, wire the credential provider up explicitly with the npmAuthenticate@0 and NuGetAuthenticate@1 tasks (a service connection covers the cross-organization case). People, as opposed to pipelines, sign in through credential providers backed by a PAT (personal access token, a long-lived string that stands in for your password). Watch storage while you are at it. Each organization gets 2 GiB of Artifacts storage free, upstream caching counts against that number, and a busy monorepo will quietly turn the feed into a line on the invoice unless you set retention policies early.
Pin it, lock it, verify it
Pinning starts with the lockfile (package-lock.json for npm, packages.lock.json for NuGet, poetry.lock for Python). A lockfile writes down the exact version that got resolved plus a SHA-512 integrity hash, a fingerprint of the file's bytes, for every package in the tree. So a build either gets byte-identical dependencies or fails loudly. In CI (continuous integration, the automated build that runs on every change), reach for the install command that treats the lockfile as law: npm ci, never npm install. npm install will happily paper over drift by resolving new versions. The attack this defeats is dependency confusion. Say @contoso/billing-core exists only on your feed. If the package manager is also allowed to consult a public registry, an attacker who publishes a public billing-core at version 99.0.0 can win the resolution race, because the resolver grabs the highest version it can see. One registry in .npmrc, scoped package names, and upstream sources close that door. Several registries prop it back open.
# Someone edited package.json without updating the lockfile — CI fails fast:$ npm cinpm error `npm ci` can only install packages when your package.json andnpm error package-lock.json are in sync. Please update your lock filenpm error with `npm install` before continuing.npm error Missing: [email protected] from lock file# In sync — exact versions, integrity hashes verified on extract:$ npm ciadded 412 packages, and audited 413 packages in 9s1 high severity vulnerability# Gate the pipeline on known-vulnerable dependencies:$ npm audit --audit-level=high# npm audit reportlodash <=4.17.20Severity: highCommand Injection - GHSA-35jh-r3h4-6jhmfix available via `npm audit fix`1 high severity vulnerability
latest, ^4.2.0, or any other range means the exact code in your build can change at any moment with no review. That gap is what dependency confusion, typosquats (packages named one keystroke away from a real one), and malicious updates all feed on. Pin instead: commit lockfiles to the repo, install with npm ci (or NuGet's RestoreLockedMode), set save-exact=true in .npmrc, and pull everything through one governed feed. On Python that means a single --index-url. Never --extra-index-url, which lets pip take the highest version it can find across *all* indexes, the attacker's public package included. Every dependency change should be a diff somebody approved.Build once, then promote the same bytes
The same rule applies to what you ship. If every environment builds from source again, staging and production are only *probably* the same. A transitive dependency (a dependency of one of your dependencies) may have shifted. A base image may have moved. A compiler flag may differ on that agent. A pipeline artifact takes the doubt out of it: you compile and test exactly once, publish the output, and every stage after that downloads those same bytes. PublishPipelineArtifact@1 is the current task. It deduplicates content and uploads only the blocks that changed, so a 200 MB drop with 4 MB of new content finishes in seconds. The older PublishBuildArtifacts@1 hangs on mainly for legacy Azure DevOps Server installs.
stages:- stage: Buildjobs:- job: buildsteps:- script: npm ci && npm run build && npm test- task: ArchiveFiles@2inputs:rootFolderOrFile: '$(System.DefaultWorkingDirectory)/dist'archiveFile: '$(Build.ArtifactStagingDirectory)/app.zip'- task: PublishPipelineArtifact@1inputs:targetPath: '$(Build.ArtifactStagingDirectory)/app.zip'artifact: drop- stage: DeployStagingdependsOn: Buildjobs:- deployment: deployenvironment: stagingstrategy:runOnce:deploy:steps: # deployment jobs auto-download current artifacts- script: ls -lh $(Pipeline.Workspace)/drop/# --- run log ---Starting: PublishPipelineArtifactUploading pipeline artifact from /home/vsts/work/1/a/app.zipUploaded 14,562,304 of 14,562,304 bytesAssociated artifact 2214 with run 4821Finishing: PublishPipelineArtifact# Verify from your terminal:$ az pipelines runs artifact list --run-id 4821 \--org https://dev.azure.com/contoso --project shop \--query "[].{id:id, name:name, type:resource.type}" -o tableId Name Type---- ------ ----------------2214 drop PipelineArtifact
Slot swap: the last promotion step
App Service deployment slots are how promotion works at the far end of this pipeline. A slot is a live, separately addressable copy of your app (shop-api-staging.azurewebsites.net) running on the production App Service plan; slots need the Standard, Premium, or Isolated tier. You deploy the artifact to the staging slot, smoke-test it against real infrastructure, then swap. Inside, a swap is a routing change and not a file copy. Azure applies the *target* slot's configuration to the staging instance, restarts it, waits for the warmup endpoint (WEBSITE_SWAP_WARMUP_PING_PATH, default /) to answer healthy, and only then flips hostname routing. Production traffic never lands on a cold start.
Configuration is the part that bites. By default, app settings and connection strings ride along with the swap. Settings you mark as slot settings (the portal calls these sticky) stay glued to the slot they belong to. Connection strings and environment names belong in that group, which is what stops staging code from booting with production credentials, and stops production from inheriting a staging endpoint.
# Deploy the SAME app.zip your pipeline published — to the staging slot$ az webapp deploy -g rg-shop -n shop-api --slot staging \--src-path app.zip --type zip --track-statusInitiating deploymentPolling the status of async deployment...Status: RuntimeSuccessfulDeployment has completed successfully# Mark environment-bound settings sticky (--slot-settings, not --settings)$ az webapp config appsettings set -g rg-shop -n shop-api --slot staging \--slot-settings APP_ENV=staging WEBSITE_SWAP_WARMUP_PING_PATH=/healthz[{ "name": "APP_ENV", "slotSetting": true, "value": "staging" },{ "name": "WEBSITE_SWAP_WARMUP_PING_PATH", "slotSetting": true,"value": "/healthz" }]# Swap: staging code goes live; sticky settings stay put$ az webapp deployment slot swap -g rg-shop -n shop-api \--slot staging --target-slot production# Rollback is the same swap again — --target-slot defaults to production$ az webapp deployment slot swap -g rg-shop -n shop-api --slot staging
This is where "build once" stops being a philosophy and starts saving your evening. Rollback is instant *because* the previous artifact is still sitting there, warm, in the other slot. Pair it with WEBSITE_RUN_FROM_PACKAGE=1: the app then runs straight from the mounted zip, wwwroot turns read-only, deployment becomes atomic, and nobody can hot-patch a file on a production instance at 2am.
Put feeds and slots in code
Slot and sticky-setting configuration clicked into the portal is configuration nobody can review and nobody can rebuild after a bad day. Declare it instead. In Bicep, the sticky list is the slotConfigNames config resource on the site. In Terraform's azurerm provider it is the sticky_settings block. One honest limit: Azure Artifacts feeds are *not* ARM (Azure Resource Manager) resources, so Bicep cannot create them, because feeds live inside Azure DevOps rather than inside an Azure subscription. Terraform's separate azuredevops provider covers that gap with azuredevops_feed, or you script the Azure DevOps REST API (the web interface Azure DevOps exposes for automation).
// Bicep: declare WHICH app settings are sticky, on the production siteresource sticky 'Microsoft.Web/sites/config@2024-04-01' = {parent: webAppname: 'slotConfigNames'properties: {appSettingNames: ['APP_ENV''WEBSITE_SWAP_WARMUP_PING_PATH']}}# Terraform (azurerm): same intent, on the web app resourceresource "azurerm_linux_web_app" "api" {# ...name, service_plan_id, site_config...sticky_settings {app_setting_names = ["APP_ENV", "WEBSITE_SWAP_WARMUP_PING_PATH"]}}$ terraform applyazurerm_linux_web_app.api: Modifying... [id=/subscriptions/.../sites/shop-api]azurerm_linux_web_app.api: Modifications complete after 27sApply complete! Resources: 0 added, 1 changed, 0 destroyed.
Carry the trade-offs with you. A feed adds a hop, so the first restore of a package is slower while the upstream caches it. Views add process. Artifact retention shows up as a real storage bill. Each one buys reproducibility, and reproducibility is what turns an incident from an archaeology dig into a diff you can read in a minute. You have now seen Bicep twice with no introduction. That introduction is next: ARM & Bicep, the resource model sitting under every az command you ran here, and how to describe a whole environment the way you described two sticky settings.
Public registries have bad days. A maintainer yanks a version, npmjs.com goes read-only for an hour, a popular package gets hijacked over a weekend. With a feed and upstream sources in front of them, your restore keeps working, because the copy it needs already sits inside your organization. Add package quality gates and a retention policy on top so the feed stays a working pantry rather than an attic nobody has opened since 2021.
Keep the two jobs separate in your head. The feed holds dependencies and the internal libraries your teams publish. Pipeline artifacts, published by PublishPipelineArtifact, hold the build output that release stages deploy. Never rebuild between environments. Promote the same artifact ID through dev, test, and production, so the thing you tested is the thing that runs.
A feed with no retention becomes a museum of every alpha build anyone ever pushed. Set retention, and promote only the versions your release trains actually consume. Then, when a CVE (a Common Vulnerabilities and Exposures ID, the public identifier given to a known flaw) lands on a transitive dependency, you have one place to bump and rebuild, instead of seventeen microservices each pinned to a different vulnerable range because somebody copy-pasted a package.json six months ago.
Try this
Create an Azure Artifacts feed, push a tiny npm or NuGet package (a universal package works too), then restore it from a pipeline using an authenticated feed endpoint. Open the lockfile afterwards and check that the version is pinned there.
az artifacts feed list --org https://dev.azure.com/<org> -p <project> -o tableaz artifacts universal download --organization https://dev.azure.com/<org> \--project <project> --scope project --feed <feed> --name hello --version 1.0.0 --path ./outls ./out
$ az artifacts feed list -p contoso -o tableName Upstream------------ --------app-packages yes# Sample output$ ls ./outhello.txt
Takeaway
Two sentences worth keeping: a feed governs what your builds are allowed to restore, and a build artifact is the frozen output you promote. Pin your versions, because a floating tag is a bet on somebody else's repository that you never got to review.
Next time you set a project up: switch upstream sources on deliberately, one public registry at a time, turn on retention before the feed starts growing, and scan the packages you pull in with the same seriousness you scan container images.
--extra-index-url makes pip take the highest version it finds across all indexes, so an attacker's public package at version 99.0.0 wins the race.npm ci pins dependencies, but each stage still builds on its own, so base-image or toolchain drift can still pull the outputs apart. It is not a byte-for-byte guarantee.