Provisions an Azure Artifacts feed together with its role assignments and retention policy behind one composite contract — organization- or project-scoped, secure-by-default
readergrants, typedfor_eachpermission collections, and a single managed retention policy. Built for azuredevops v1.x.
This composite module creates and wires together everything a packaging feed needs:
- 📦 An Azure Artifacts feed (
azuredevops_feed) — host NuGet, npm, Maven, Python, Cargo, and Universal Packages. - 🌐 Organization- or project-scoped placement via an optional
project_id(omit for an org-level feed). - 👥 Feed role assignments (
azuredevops_feed_permission) — a typedfor_eachmap grantingreader/collaborator/contributor/administratorto identity descriptors. - 🧹 A retention policy (
azuredevops_feed_retention_policy) — automatically prunes old package versions while protecting recently downloaded ones. - 🔧 Optional feed features (
permanent_delete,restore) and per-resource timeouts.
💡 Why it matters: a feed is useless until the right pipelines and people can read and publish to it, and unbounded without retention. This module turns "create a feed, then click through Feed Settings" into one reviewable, version-pinned declaration.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
project["terraform-azuredevops-project<br/>(emits project_id)"]
feed["terraform-azuredevops-feed<br/>(this module)"]
artifacts["terraform-azuredevops-<br/>serviceendpoint_artifacts"]
group["terraform-azuredevops-group<br/>(emits descriptor)"]
pipelines["build / release<br/>pipelines"]
project -. "project_id (optional)".-> feed
group -. "identity_descriptor".-> feed
feed -- "feed_id" --> artifacts
feed -- "feed_id" --> pipelines
style feed fill:#8957E5,color:#fff
style project fill:#0078D4,color:#fff
project_idis optional here — unlike most of the suite, the feed keystone can live at the organization level with no project at all.identity_descriptorvalues typically come fromterraform-azuredevops-group(descriptor) or built-in/Entra identities.- Downstream, the emitted
feed_idwires into artifact service connections and pipelines that publish/consume packages.
flowchart TD
feed["azuredevops_feed.this<br/>(keystone — org or project scoped)"]
perm["azuredevops_feed_permission.this<br/>for_each = var.permissions<br/>(map, 0..N role grants)"]
ret["azuredevops_feed_retention_policy.this<br/>for_each = retention_policy != null ? {policy=…}: {}<br/>(0..1 per feed)"]
feed -- "feed_id" --> perm
feed -- "feed_id" --> ret
style feed fill:#8957E5,color:#fff
style perm fill:#161B22,color:#fff
style ret fill:#161B22,color:#fff
Resource inventory (3 resources):
azuredevops_feed.this— the keystone feed. Renders optionalfeaturesandtimeoutsdynamic blocks.azuredevops_feed_permission.this—for_eachovervar.permissions(map(object)), one role assignment per entry. Nocount.azuredevops_feed_retention_policy.this—for_eachover a{ policy = … }toggle map so the policy is created 0-or-1 times withoutcount.
Children inherit the keystone's project_id by default and may override it per entry.
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
microsoft/azuredevops |
>= 1.0, < 2.0 (current GA line v1.15.x) |
The module declares the provider requirement only — it configures no provider {} block. The root module supplies the org URL and auth (PAT or Azure AD service principal).
The Terraform identity must be able to create feeds and administer feed permissions and settings. In Azure Artifacts terms that means the running identity is a Feed Owner on the target feed (granted automatically to Project Collection Administrators and Azure Artifacts Administrators).
| Scope / Role | PAT scope | Service-principal role | Required for |
|---|---|---|---|
| Packaging | Packaging (Read, Write & Manage) |
Feed Owner (auto for PCA / Azure Artifacts Admin) | Creating/updating/deleting azuredevops_feed |
| Feed administration | Packaging (Read, Write & Manage) |
Feed Owner on the feed | Managing azuredevops_feed_permission (only Feed Owners can add/remove role assignments) |
| Retention policy | Packaging (Read, Write & Manage) |
Feed Owner on the feed | Managing azuredevops_feed_retention_policy (Feed Owner required to set retention) |
| Project scope (optional) | Project and Team (Read) |
member of the target project | Resolving project_id for a project-scoped feed |
⚠️ Organization-scoped feeds and "who can create feeds" are collection-level controls. Creating an org-level feed (or any feed where the identity is not already a Feed Owner) requires Project Collection Administrator rights, or an explicit Azure Artifacts "Who can create feeds / Who can administer feeds" delegation. Confirm the running identity is granted the collection-level role beforeapply— a missing grant surfaces as a403 Forbidden, not a validation error.
terraform-azuredevops-feed/
├── providers.tf # terraform >= 1.12, azuredevops >= 1.0 < 2.0 — no provider{} block
├── variables.tf # name, project_id, features, permissions, retention_policy, timeouts
├── main.tf # azuredevops_feed.this + feed_permission.this + feed_retention_policy.this
├── outputs.tf # id, feed_id, name, project_id, permission_ids, permission_identity_ids, retention_policy_id
├── SCOPE.md # cross-module contract + required scopes/auth + provider gotchas
└── README.md # this file
Smallest working call — an organization-scoped feed:
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "casey-shared-packages"
}A project-scoped feed, wiring project_id from terraform-azuredevops-project:
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "lending-platform-packages"
project_id = module.project.project_id
}| Input | Type | Source module |
|---|---|---|
project_id (optional) |
string |
terraform-azuredevops-project — omit for an organization-scoped feed |
permissions[*].identity_descriptor |
string |
terraform-azuredevops-group (descriptor), built-in or Entra group descriptors |
ℹ️ This module is organization-scoped by default —
project_idis optional, not required. There are no other cross-module inputs; the org URL and auth come from the provider configuration in the root.
| Output | Description | Consumed by |
|---|---|---|
id |
The feed ID (primary output) | downstream module references |
feed_id |
Resource-specific feed ID | terraform-azuredevops-serviceendpoint-artifacts, publish/restore pipeline tasks |
name |
Feed name | logging / audit |
project_id |
Project ID, or null when org-scoped |
scope-aware wiring / audit |
permission_ids |
Map of permission resource IDs by handle | audit |
permission_identity_ids |
Map of resolved identity IDs by handle | audit |
retention_policy_id |
Retention policy ID, or null when none |
audit |
ℹ️ No sensitive outputs. This resource family emits only IDs, names, and identity descriptors — never secrets.
- Org-vs-project scope is chosen once and is effectively permanent. Per Microsoft Learn, organization-scoped feeds cannot be converted to project-scoped feeds. The provider treats a
project_idchange as a destroy/recreate, and recreation can collide with the name-reservation window (below). Decide scope deliberately. - Feed name reservation. Because of an ADO limitation, a feed name can be reserved for up to 15 minutes after a permanent delete. A rename or scope change (both recreate the feed) may fail with a transient name conflict during that window — re-run the apply after it clears.
- Role names map to the Artifacts UI as follows:
Module role |
Azure Artifacts UI role | Can… |
|---|---|---|
reader |
Feed Reader | list / download / restore packages |
collaborator |
Feed and Upstream Reader | + save packages from upstream sources |
contributor |
Feed Publisher | + publish / promote / deprecate packages |
administrator |
Feed Owner | + delete packages, edit settings, manage upstreams & permissions |
The default consumer grant should be reader; reserve administrator for the few identities that must manage the feed.
- Retention deletes only when BOTH conditions are met. A package version is removed only when the maximum versions per package (
count_limit) is reached and that version hasn't been downloaded withindays_to_keep_recently_downloaded_packages. Packages promoted to a view (@release,@prerelease) are exempt from retention. - Single retention policy per feed. The retention policy is modeled as an
optional(object)rendered through a{ policy = … }toggle map — at most one exists.feed_idandproject_idon the policy are immutable. - Eventual consistency. Feed and permission writes propagate asynchronously across the Artifacts service. A freshly applied permission may take a short time to take effect for a pipeline's build identity.
- Pipeline access is the common gotcha, not a module input. Pipelines authenticate as a Build Service identity (
[Project] Build Service ([Org])orProject Collection Build Service ([Org])). To let a pipeline publish, add that identity topermissionswithcontributor. New org-scoped feeds grant the Project Collection Build Service thecollaboratorrole by default. - No secrets, no
sensitive. Identity descriptors are references, not credentials, so the module declares no sensitive variables or outputs and needs nononsensitiveunwrapping.
1 · Organization-scoped feed (minimal)
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "casey-shared-packages"
}2 · Project-scoped feed
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "lending-platform-packages"
project_id = module.project.project_id
}3 · Feed with a retention policy
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "lending-platform-packages"
project_id = module.project.project_id
retention_policy = {
count_limit = 30 # keep up to 30 versions per package
days_to_keep_recently_downloaded_packages = 30 # but never prune anything downloaded in the last 30 days
}
}4 · Read-only access for a team (secure default)
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "lending-platform-packages"
project_id = module.project.project_id
permissions = {
developers = {
identity_descriptor = module.dev_group.descriptor
role = "reader"
display_name = "Lending Platform Developers"
}
}
}5 · Grant a pipeline build identity publish rights
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "lending-platform-packages"
project_id = module.project.project_id
permissions = {
# The pipeline's project Build Service identity must be a Contributor to publish.
build_service = {
identity_descriptor = data.azuredevops_group.build_service.descriptor
role = "contributor"
display_name = "Project Build Service (publish)"
}
}
}6 · Tiered roles — readers, publishers, owners
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "platform-packages"
project_id = module.project.project_id
permissions = {
consumers = {
identity_descriptor = module.consumers_group.descriptor
role = "reader"
}
publishers = {
identity_descriptor = module.publishers_group.descriptor
role = "contributor"
}
owners = {
identity_descriptor = module.platform_admins.descriptor
role = "administrator"
display_name = "Platform Admins (feed owners)"
}
}
}7 · Collaborator role (read + save from upstream sources)
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "casey-upstream-cache"
permissions = {
# Collaborators can pull packages from upstream sources into the feed.
engineers = {
identity_descriptor = module.engineers_group.descriptor
role = "collaborator"
}
}
}8 · Feed features — permanent delete and restore
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "ephemeral-ci-feed"
features = {
permanent_delete = true # purge immediately on destroy (skips soft-delete recycle)
restore = false
}
}9 · Restore a soft-deleted feed on create
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "recovered-packages"
features = {
restore = true # attempt to restore a soft-deleted feed of this name
permanent_delete = false
}
}10 · Per-permission project_id override
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
# Organization-scoped feed (no module-level project_id) …
name = "org-shared-feed"
permissions = {
# … but this grant is evaluated in the context of a specific project.
proj_team = {
identity_descriptor = module.team_group.descriptor
role = "reader"
project_id = module.lending_project.project_id
}
}
}11 · Custom timeouts on the feed and a child
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "slow-region-feed"
timeouts = {
create = "15m"
delete = "15m"
}
retention_policy = {
count_limit = 50
days_to_keep_recently_downloaded_packages = 14
timeouts = {
create = "15m"
}
}
}12 · Retention + read access (typical project feed)
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "lending-platform-packages"
project_id = module.project.project_id
retention_policy = {
count_limit = 25
days_to_keep_recently_downloaded_packages = 30
}
permissions = {
developers = {
identity_descriptor = module.dev_group.descriptor
role = "reader"
}
ci = {
identity_descriptor = data.azuredevops_group.build_service.descriptor
role = "contributor"
}
}
}13 · Consuming feed_id in an artifacts service connection
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "platform-packages"
project_id = module.project.project_id
}
# A downstream artifacts endpoint / pipeline references the emitted feed_id.
output "feed_for_pipeline" {
value = module.feed.feed_id
}14 · End-to-end composition (project → group → feed) — finale
# 1) Foundation project
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Lending Platform"
}
# 2) An identity group to grant feed access to
module "dev_group" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-group?ref=v1.0.0"
project_id = module.project.project_id
display_name = "Lending Platform Developers"
}
# 3) Build service identity (existing)
data "azuredevops_group" "build_service" {
project_id = module.project.project_id
name = "Project Collection Build Service Accounts"
}
# 4) The feed, wiring project_id + descriptors, with retention and tiered roles
module "feed" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"
name = "lending-platform-packages"
project_id = module.project.project_id
features = {
permanent_delete = false
restore = false
}
retention_policy = {
count_limit = 30
days_to_keep_recently_downloaded_packages = 30
}
permissions = {
developers = {
identity_descriptor = module.dev_group.descriptor
role = "reader"
display_name = "Developers (read)"
}
ci_publish = {
identity_descriptor = data.azuredevops_group.build_service.descriptor
role = "contributor"
display_name = "CI publish"
}
}
}
output "feed_id" {
value = module.feed.feed_id
}Full input schema
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | ✅ | Feed name. Effectively immutable (recreate on change); reserved up to 15 min after permanent delete. |
project_id |
string |
null |
— | Project scope. null ⇒ organization-scoped. Immutable. Also the default scope for children. |
features |
object |
null |
— | { permanent_delete = optional(bool,false), restore = optional(bool,false) } |
permissions |
map(object) |
{} |
— | Role assignments keyed by handle. See schema below. |
retention_policy |
object |
null |
— | At-most-one retention policy. See schema below. |
timeouts |
object |
{} |
— | { create, read, update, delete } (all optional(string)) for the feed. |
permissions entry schema
"<handle>" = {
identity_descriptor = string # required — identity descriptor (e.g. group.descriptor)
role = string # required — reader | contributor | collaborator | administrator
display_name = optional(string) # assignment display name
project_id = optional(string) # overrides module project_id for this entry
timeouts = optional(object({ create, read, update, delete })) # all optional(string)
}retention_policy schema
{
count_limit = number # required — max versions per package (> 0)
days_to_keep_recently_downloaded_packages = number # required — grace days (>= 0)
project_id = optional(string) # overrides module project_id; immutable
timeouts = optional(object({ create, read, update, delete }))
}Validation enforced at plan time
namemust be non-empty.- Each permission
role∈{reader, contributor, collaborator, administrator}. - Each permission
identity_descriptormust be non-empty. retention_policy.count_limit > 0anddays_to_keep_recently_downloaded_packages >= 0.
| Output | Description | Sensitive |
|---|---|---|
id |
The feed ID (primary output). | no |
feed_id |
Resource-specific feed ID for cross-module wiring. | no |
name |
The feed name. | no |
project_id |
Project ID, or null when org-scoped. |
no |
permission_ids |
Map of feed-permission resource IDs by handle. | no |
permission_identity_ids |
Map of resolved identity IDs by handle. | no |
retention_policy_id |
Retention policy ID, or null when none (try-guarded). |
no |
ℹ️ No sensitive outputs — the module surfaces IDs and descriptors only.
- One keystone, typed children.
azuredevops_feed.thisis the single primary resource; permissions and retention are children rendered withfor_eachovermap(object(...))— nevercount. - Make the type the contract. Deeply-typed
objectschemas,optionalwith safe defaults,validation {}for every closed value set, heredoc descriptions carrying the schema inline. - Secure by default.
readeris the recommended consumer grant; the empty call creates a feed with no extra grants and no destructive feature flags. - Total renderer.
main.tfis a pure projection —dynamicblocks for every optional/repeating block,try(x, null)on every optional nested field. - Scope-flexible.
project_idis optional everywhere, and children inherit it with a per-entry override.
cd C:\GitHubCode\newazuredevopsmodules\terraform-azuredevops-feed
terraform init -backend=false
terraform validate
terraform fmt -check
⚠️ terraform plan/applyrequire live organization credentials (org URL + PAT, or an Azure AD service principal). The offline gate above verifies structural correctness. For live testing use a non-production organization with a dedicated identity holding the Packaging scope — never the production org.
- ✅
terraform init -backend=false— provider resolves (microsoft/azuredevops >= 1.0, < 2.0). - ✅
terraform validate— Success! The configuration is valid. - ✅
terraform fmt -check— no formatting differences. - 🔁 Type-level negative tests: an invalid
role, an emptyidentity_descriptor, orcount_limit = 0each fail at plan time with an actionableerror_message. - 🌐 Live (non-prod org) smoke test: apply minimal org-scoped feed → confirm in Artifacts; add a
readerpermission → confirm in Feed Settings › Permissions.
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
feed_id = "11111111-2222-3333-4444-555555555555"
id = "11111111-2222-3333-4444-555555555555"
name = "lending-platform-packages"
permission_ids = { "developers" = "…" }
project_id = "00000000-0000-0000-0000-000000000000"
retention_policy_id = "…"
| Symptom | Likely cause | Fix |
|---|---|---|
403 Forbidden / TF400813 on feed create |
Identity lacks Packaging (Read, Write & Manage) or is not a Feed Owner / PCA | Grant the PAT scope, or add the identity as Feed Owner / Project Collection Administrator (org-scoped feeds need collection-level rights). |
403 only when managing permissions or retention |
Identity can read the feed but is not a Feed Owner | Only Feed Owners may add/remove role assignments or set retention. Elevate the running identity. |
| Permission has no effect for a pipeline | Wrong Build Service identity, or eventual consistency | Use the correct [Project] Build Service ([Org]) / Project Collection Build Service ([Org]) descriptor; re-run after propagation. |
Plan wants to recreate the feed after editing project_id |
Feed scope is immutable; org-scoped feeds can't become project-scoped | Decide scope once. If a move is truly required, create a new feed and migrate packages. |
| Feed create fails with a name conflict shortly after a delete | Name reserved up to 15 minutes after permanent delete | Wait out the reservation window and re-apply, or choose a different name. |
| Old versions not being deleted despite retention | Retention deletes only when both conditions met, and view-promoted packages are exempt | Lower count_limit / days_to_keep_recently_downloaded_packages; remember @release/@prerelease packages are protected. |
Invalid value for variable … role at plan time |
role outside the enum |
Use reader, contributor, collaborator, or administrator. |
- Azure Artifacts feeds — key concepts
- Feed scopes: project vs organization
- Manage feed permissions & roles
- Retention policies — delete & recover packages
- Package sizes and count limits
- Terraform:
azuredevops_feed·azuredevops_feed_permission·azuredevops_feed_retention_policy SCOPE.md— cross-module contract & required scopes/auth
💙 "Infrastructure as Code should be standardized, consistent, and secure."

