The foundation composite of the Azure DevOps suite — an Azure DevOps project plus its features, pipeline security settings, tags, and project-level permissions, behind one deeply-typed, organization-scoped boundary. Its
project_idis the recurring wire-in for nearly every other module. Built for azuredevops v1.x.
This module provisions an Azure DevOps project and the core settings that travel with it:
- 🏗️ The project (
azuredevops_project) — name, description, visibility, version control, and work-item process. Secure-by-default:private+Git+Agile. - 🎛️ Features (
azuredevops_project_features) — toggle Boards, Repos, Pipelines, Test Plans, and Artifacts on or off. - 🛡️ Pipeline settings (
azuredevops_project_pipeline_settings) — job-scope enforcement, repo-scoped tokens, settable-var limits, badge privacy. - 🏷️ Tags (
azuredevops_project_tags) — project labels (NOT the azurermtagspattern). - 🔐 Permissions (
azuredevops_project_permissions) — project-level ACLs assigned to group principals, default-deny.
💡 Why it matters: every other module in the suite (
git_repository,variable_group,build_definition,branch_policies, …) wires from this module'sproject_id. Making the project and its baseline settings a single, reviewable, version-pinned unit means the foundation is consistent and secure before anything else is built on top of it.
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!
terraform-azuredevops-project is the root of the dependency graph. Almost everything flows from its project_id.
flowchart TD
project["terraform-azuredevops-project"]
repo["terraform-azuredevops-git-repository"]
vg["terraform-azuredevops-variable-group"]
build["terraform-azuredevops-build-definition"]
bpol["terraform-azuredevops-branch-policies"]
rpol["terraform-azuredevops-repository-policies"]
env["terraform-azuredevops-environment"]
se["terraform-azuredevops-serviceendpoint-azure"]
project -->|project_id| repo
project -->|project_id| vg
project -->|project_id| build
project -->|project_id| bpol
project -->|project_id| rpol
project -->|project_id| env
project -->|project_id| se
repo -->|repository_id| build
style project fill:#8957E5,color:#fff
The keystone azuredevops_project.this (purple) creates the project; its id feeds project_id into four for_each children. The three singleton children render 0-or-1 via a guarded for_each; permissions is a true keyed collection.
flowchart TD
this["azuredevops_project<br/>(this) — keystone (creates the project)"]
feat["azuredevops_project_features<br/>for_each (0-or-1, var.features)"]
pset["azuredevops_project_pipeline_settings<br/>for_each (0-or-1, var.pipeline_settings)"]
tags["azuredevops_project_tags<br/>for_each (0-or-1, var.tags)"]
perm["azuredevops_project_permissions<br/>for_each var.permissions"]
this -->|project_id = this.id| feat
this -->|project_id = this.id| pset
this -->|project_id = this.id| tags
this -->|project_id = this.id| perm
style this fill:#8957E5,color:#fff
Resource inventory
azuredevops_project.this— the project (primarythis).azuredevops_project_features.this— guardedfor_each; present whenvar.features != null.azuredevops_project_pipeline_settings.this— guardedfor_each; present whenvar.pipeline_settings != null.azuredevops_project_tags.this— guardedfor_each; present whenlength(var.tags) > 0.azuredevops_project_permissions.this—for_eachovervar.permissions.
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
microsoft/azuredevops |
>= 1.0, < 2.0 (validated against v1.15.x) |
The module declares the provider requirement only — it configures no provider {} block. The root/spec configures the organization URL and credentials (PAT or Azure AD service principal).
| Scope / Role | PAT scope | Service-principal role | Required for |
|---|---|---|---|
| Project & Team (create) | Project and Team (Read, Write & Manage) — vso.project_manage |
Project Collection Administrators (or Organization Owner) | creating the project (keystone), name/description/visibility |
| Project & Team (settings) | Project and Team (Read, Write & Manage) — vso.project_write |
Project Administrators | project_features, project_tags |
| Security (manage) | vso.security_manage |
Project Administrators (collection-level node needs PCA) | project_permissions — project ACLs |
| Pipeline settings | Full access | Project Administrators | project_pipeline_settings |
⚠️ Creating a project — and changing its visibility — requires Project Collection Administrator or Organization Owner. Assigning project-level ACLs requires at least Project Administrators; setting permissions on the collection-level node requires Project Collection Administrators. These are not held by Contributors by default — grant them to the Terraform identity deliberately.
terraform-azuredevops-project/
├── providers.tf # azuredevops >= 1.0, < 2.0; no provider {} block
├── variables.tf # name → project config → features/pipeline_settings/tags/permissions → timeouts
├── main.tf # azuredevops_project.this + 4 for_each children
├── outputs.tf # id, project_id, name, state passthrough, per-collection maps
├── SCOPE.md # cross-module contract + required scopes/auth + gotchas
└── README.md # this file
Smallest working call — a secure-by-default private Git project:
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
# visibility = "private", version_control = "Git", work_item_template = "Agile" by default
}Then wire module.project.project_id into every project-scoped sibling module.
This module is organization-scoped — it has no project_id input. The keystone creates the project; its id becomes the project_id everything else consumes. The only configuration "consumed" is the provider's organization URL + credentials, set at the root.
| Input | Type | Source |
|---|---|---|
| (none — no cross-module inputs) | — | Organization configured on the provider (org URL + PAT / service principal) |
| Output | Description | Consumed by |
|---|---|---|
id |
Primary project ID | downstream module references |
project_id |
Resource-specific project ID | every project-scoped sibling module (project_id) |
name |
Project name | logging / audit |
description · visibility · version_control · work_item_template |
Project state passthrough | audit / conditional wiring |
process_template_id |
Resolved process template ID (computed) | audit, process-scoped tooling |
features |
Map of managed feature → status (empty when unmanaged) | audit |
pipeline_settings_id |
Pipeline settings resource ID (null when unmanaged) | audit |
tags |
Managed project tags (empty when unmanaged) | audit |
permission_ids |
Map of permission handle → ACL resource ID | audit |
🔒 No sensitive outputs — this resource family carries no secrets. The module emits IDs and state attributes only.
1 · Minimal private Git project (secure defaults)
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
}2 · Project with a description
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
description = "Core payments platform — managed by Terraform."
}3 · Choose the work-item process
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
work_item_template = "Scrum" # "Agile" (default) | "Basic" | "CMMI" | "Scrum" | a custom inherited process
}Effectively immutable — changing the process after creation forces recreate.
4 · Use the parent organization's default process
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
work_item_template = "" # empty string → inherit the org default process
}5 · TFVC project (opt out of Git)
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "LegacyTfvc"
version_control = "Tfvc" # IMMUTABLE — forces recreate if changed
}6 · Toggle project features
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
features = {
boards = "enabled"
repositories = "enabled"
pipelines = "enabled"
testplans = "disabled"
artifacts = "disabled"
}
}Managed via the dedicated
azuredevops_project_featuresresource — set only the features you want to manage; omit the rest.
7 · Harden pipeline settings (secure-by-default block)
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
# Empty object applies the hardened defaults (all enforce_* = true, badges private,
# metadata not published). Override individual fields as needed.
pipeline_settings = {}
}8 · Pipeline settings with explicit overrides
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
pipeline_settings = {
enforce_job_scope = true
enforce_referenced_repo_scoped_token = true
enforce_settable_var = true
publish_pipeline_metadata = false
status_badges_are_private = true
enforce_job_scope_for_release = true
}
}
⚠️ Organization-level settings override project settings — setting a fieldfalsehere when the org enforcestrueyields a perpetual diff.
9 · Project tags (labels)
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
tags = ["business-critical", "pci", "team-payments"]
}These are Azure DevOps project labels — NOT the azurerm
tagspattern.
10 · Project-level permissions (default-deny posture)
data "azuredevops_group" "contributors" {
project_id = module.project.project_id
name = "Contributors"
}
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
permissions = {
contributors_lockdown = {
principal = data.azuredevops_group.contributors.id
permissions = {
DELETE = "Deny"
RENAME = "Deny"
UPDATE_VISIBILITY = "Deny"
GENERIC_READ = "Allow"
}
}
}
}
project_idis wired automatically. Permissions target group descriptors — never individual users.
11 · Merge (not replace) permissions onto an existing ACL
permissions = {
grant_admins = {
principal = data.azuredevops_group.admins.id
permissions = {
MANAGE_PROPERTIES = "Allow"
CHANGE_PROCESS = "Allow"
}
replace = false # merge with the group's existing ACL instead of replacing it
}
}12 · Multiple permission assignments keyed by handle
permissions = {
readers = {
principal = data.azuredevops_group.readers.id
permissions = { GENERIC_READ = "Allow", DELETE = "Deny" }
}
build_admins = {
principal = data.azuredevops_group.build_admins.id
permissions = { ADMINISTER_BUILD = "Allow", START_BUILD = "Allow" }
}
}13 · Operation timeouts
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
timeouts = {
create = "10m"
delete = "10m"
}
# per-child timeouts also supported on features / pipeline_settings / permissions entries
features = {
pipelines = "enabled"
timeouts = { create = "10m" }
}
}14 · Fully-configured project (all children)
data "azuredevops_group" "contributors" {
project_id = module.project.project_id
name = "Contributors"
}
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
description = "Core payments platform — managed by Terraform."
visibility = "private"
version_control = "Git"
work_item_template = "Agile"
features = {
boards = "enabled"
repositories = "enabled"
pipelines = "enabled"
testplans = "disabled"
artifacts = "enabled"
}
pipeline_settings = {} # hardened defaults
tags = ["pci", "team-payments"]
permissions = {
contributors = {
principal = data.azuredevops_group.contributors.id
permissions = { GENERIC_READ = "Allow", DELETE = "Deny" }
}
}
}15 · 🎯 End-to-end composition (mandatory finale)
The foundation wired into the rest of the suite — project → repository → branch policies → build definition, all by ID.
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments"
description = "Core payments platform."
visibility = "private"
version_control = "Git"
work_item_template = "Agile"
features = { artifacts = "enabled", testplans = "disabled" }
pipeline_settings = {} # hardened defaults
}
module "repo" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-git-repository?ref=v1.0.0"
name = "payments-api"
project_id = module.project.project_id # ← the headline wire-in
default_branch = "refs/heads/main"
}
module "branch_policies" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-branch-policies?ref=v1.0.0"
project_id = module.project.project_id
repository_id = module.repo.repository_id
}
module "build" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-build-definition?ref=v1.0.0"
name = "payments-api-ci"
project_id = module.project.project_id
repository_id = module.repo.repository_id
}Full input schema
| Name | Type | Default | Description |
|---|---|---|---|
name |
string |
— (required) | Project name. Mutable (rename in place). |
description |
string |
null |
Project description. |
visibility |
string |
"private" |
"private" | "public". Secure default; public projects are retired. |
version_control |
string |
"Git" |
"Git" | "Tfvc". IMMUTABLE. |
work_item_template |
string |
"Agile" |
Process: Agile/Basic/CMMI/Scrum/custom; "" = org default. Effectively IMMUTABLE. |
features |
object |
null |
Feature toggles → azuredevops_project_features. See schema below. |
pipeline_settings |
object |
null |
Pipeline security → azuredevops_project_pipeline_settings. See schema below. |
tags |
list(string) |
[] |
Project labels → azuredevops_project_tags. |
permissions |
map(object) |
{} |
Project ACLs → azuredevops_project_permissions, keyed by handle. |
timeouts |
object |
{} |
Operation timeouts for the project (create/read/update/delete). |
features
object({
boards = optional(string) # "enabled" | "disabled"
repositories = optional(string)
pipelines = optional(string)
testplans = optional(string)
artifacts = optional(string)
timeouts = optional(object({ create, read, update, delete }))
})Managed via
azuredevops_project_features(NOT the keystone's inline block — the provider forbids both). At least one feature must be set when non-null.
pipeline_settings (defaults are the hardened posture)
object({
enforce_job_scope = optional(bool, true)
enforce_referenced_repo_scoped_token = optional(bool, true)
enforce_settable_var = optional(bool, true)
publish_pipeline_metadata = optional(bool, false)
status_badges_are_private = optional(bool, true)
enforce_job_scope_for_release = optional(bool, true)
timeouts = optional(object({ create, read, update, delete }))
})permissions[*]
map(object({
principal = string # group descriptor (required)
permissions = map(string) # name => "Allow" | "Deny" | "NotSet"
replace = optional(bool, true)
timeouts = optional(object({ create, read, update, delete }))
}))Valid permission names: GENERIC_READ, GENERIC_WRITE, DELETE, PUBLISH_TEST_RESULTS, ADMINISTER_BUILD, START_BUILD, EDIT_BUILD_STATUS, UPDATE_BUILD, DELETE_TEST_RESULTS, VIEW_TEST_RESULTS, MANAGE_TEST_ENVIRONMENTS, MANAGE_TEST_CONFIGURATIONS, WORK_ITEM_DELETE, WORK_ITEM_MOVE, WORK_ITEM_PERMANENTLY_DELETE, RENAME, MANAGE_PROPERTIES, MANAGE_SYSTEM_PROPERTIES, BYPASS_PROPERTY_CACHE, BYPASS_RULES, SUPPRESS_NOTIFICATIONS, UPDATE_VISIBILITY, CHANGE_PROCESS, AGILETOOLS_BACKLOG, AGILETOOLS_PLANS.
| Output | Description | Sensitive |
|---|---|---|
id |
Project ID (primary) | — |
project_id |
Project ID (resource-specific, the headline wire-in) | — |
name |
Project name | — |
description |
Project description | — |
visibility |
private | public |
— |
version_control |
Git | Tfvc |
— |
work_item_template |
Applied process name | — |
process_template_id |
Resolved process template ID (computed) | — |
features |
Map: managed feature → status (empty when unmanaged) | — |
pipeline_settings_id |
Pipeline settings resource ID (null when unmanaged) | — |
tags |
Managed project tags (empty when unmanaged) | — |
permission_ids |
Map: permission handle → ACL resource ID | — |
ℹ️ No sensitive outputs — the module emits IDs and state attributes only. This resource family carries no secrets.
- Organization-scoped keystone. Unlike most of the suite,
azuredevops_projecttakes noproject_id— it creates the project, and its ownidIS theproject_idconsumed downstream. The four children wireproject_id = azuredevops_project.this.id. - Features managed one way only. The provider forbids managing features via both the keystone's inline
featuresblock andazuredevops_project_features. This module leaves the inline block unset and manages features solely through the dedicated resource — set only the features you want to manage. - Public projects are retired. New public projects can no longer be created (only orgs grandfathered on the legacy Allow public project policy), and existing public projects auto-convert to private in 2027.
visibilitydefaults toprivate, andpublic → privateis one-way. - Process & version control are effectively immutable. Changing
work_item_template(process) orversion_controlafter creation forces destroy/recreate.process_template_idis computed (read-only) and surfaced as an output. - Pipeline settings: org overrides project. Organization-level pipeline settings win. Setting a field
falsehere when the org enforces ittrueproduces a perpetual diff — manage that field at the org instead. - Permissions are group-scoped and inherited. ACLs target group descriptors, never individual users; security groups are managed at the org/collection level even when scoped to a project. A
Denyat any level beats anAllow— favourNotSet(inherit) andDenyover blanketAllow. - Eventual consistency. Project creation is eventually consistent — dependent resources may briefly 404 right after apply, and a just-created group referenced in
permissionsmay need a retry as identity propagation completes. - Version control note. Azure Repos Git is browse/clone over HTTPS only — SSH and GVFS endpoints are unavailable for the connected experience.
- Type is the contract — deeply-typed
objectschemas (features and pipeline_settings are typed objects, not loose maps); a malformed input is a plan-time type error. - One keystone
this+ fourfor_eachchildren — singletons render 0-or-1 via guardedfor_each,permissionsis a keyed collection — nocount. optionalwith secure defaults —visibility = "private",version_control = "Git",work_item_template = "Agile", and hardened pipeline-settings defaults (enforce_* = true, badges private).validation {}for closed sets —visibility,version_control, feature values (enabled/disabled), permission action values (Allow/Deny/NotSet), and the full project permission name set.work_item_templateis intentionally not enum-validated because custom inherited processes are allowed.dynamic+try(x, null)makemain.tfa total renderer; optional blocks render only when set.id+project_idare the primary outputs (neverobject_id).
cd C:\GitHubCode\newazuredevopsmodules\terraform-azuredevops-project
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan/applyrequire live organization credentials (org URL + PAT, or an Azure AD service principal) with Project Collection Administrator rights to create projects. The offline gate above is sufficient for structural correctness. For live testing, use a non-production Azure DevOps organization with a dedicated identity — never the production org.
- ✅
terraform init -backend=false— providermicrosoft/azuredevopsv1.15.x installed. - ✅
terraform validate—Success! The configuration is valid. - ✅
terraform fmt -check— clean, no diff. - 🔬 Live integration (optional, non-production org only): apply, then confirm via the Azure DevOps web portal or the read-only ADO MCP that the project, features, pipeline settings, tags, and ACLs landed as expected.
Outputs:
id = "11111111-2222-3333-4444-555555555555"
project_id = "11111111-2222-3333-4444-555555555555"
name = "Payments"
visibility = "private"
version_control = "Git"
work_item_template = "Agile"
process_template_id = "adcc42ab-9882-485e-a3ed-7678f01f66bc"
features = { artifacts = "enabled", testplans = "disabled" }
pipeline_settings_id = "11111111-2222-3333-4444-555555555555"
tags = ["pci", "team-payments"]
permission_ids = { contributors = "..." }
| Symptom | Likely cause | Fix |
|---|---|---|
401/403 creating the project |
PAT missing Project & Team (Read, Write & Manage) or identity not Project Collection Administrator | Grant the scopes in Required Azure DevOps Scopes / Auth; project creation needs the collection-level role. |
403 setting visibility = "public" |
Org is not grandfathered on Allow public project | Public projects are retired — keep visibility = "private". |
403 on project_permissions |
PAT missing vso.security_manage / SP lacks Manage permissions |
Grant security-manage; collection-level node changes need Project Collection Administrators. |
| Conflict / duplicate features error | Features managed both inline and via project_features |
This module manages features only via azuredevops_project_features — do not also set features elsewhere on the same project. |
Perpetual diff on a pipeline_settings field |
Org-level setting overrides the project | Manage that field at the organization, or align the project value with the org. |
Recreate on work_item_template / version_control change |
These are effectively immutable | Expect destroy/recreate; plan accordingly. |
TF400898 / project not found by a sibling module |
Passed a project name where the ID is expected, or wrong org configured | Wire project_id from this module's project_id output; verify the provider's org URL. |
| Permission "does nothing" / principal not found | principal is a user or an unresolved/just-created group |
Use a group descriptor; allow for identity propagation and re-apply. |
| Sibling resource 404s immediately after create | Project creation is eventually consistent | Re-run apply; the dependency settles within a few minutes. |
azuredevops_projectazuredevops_project_featuresazuredevops_project_pipeline_settingsazuredevops_project_tagsazuredevops_project_permissions- About projects and scaling your organization
- Public projects retirement
- Project-level permissions reference
- About process customization and inherited processes
- Sibling modules:
terraform-azuredevops-git-repository,terraform-azuredevops-variable-group,terraform-azuredevops-build-definition,terraform-azuredevops-branch-policies
💙 "Infrastructure as Code should be standardized, consistent, and secure."

