Skip to content

About

Terraform module: terraform-azuredevops-project

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

🔷 Azure DevOps Project Terraform Module

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_id is the recurring wire-in for nearly every other module. Built for azuredevops v1.x.

Terraform azuredevops module type resources


🧩 Overview

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 azurerm tags pattern).
  • 🔐 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's project_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.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


🗺️ Where this fits in the family

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
Loading

🧬 What this module builds

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
Loading

Resource inventory

  • azuredevops_project.this — the project (primary this).
  • azuredevops_project_features.this — guarded for_each; present when var.features != null.
  • azuredevops_project_pipeline_settings.this — guarded for_each; present when var.pipeline_settings != null.
  • azuredevops_project_tags.this — guarded for_each; present when length(var.tags) > 0.
  • azuredevops_project_permissions.this — for_each over var.permissions.

✅ Provider / Versions

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).


🔑 Required Azure DevOps Scopes / Auth

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.


📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

Consumes

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)

Emits

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.


📚 Example Library

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_features resource — 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 field false here when the org enforces true yields 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 tags pattern.

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_id is 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
}

📥 Inputs

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.


🧾 Outputs

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.


🧠 Architecture Notes

  • Organization-scoped keystone. Unlike most of the suite, azuredevops_project takes no project_id — it creates the project, and its own id IS the project_id consumed downstream. The four children wire project_id = azuredevops_project.this.id.
  • Features managed one way only. The provider forbids managing features via both the keystone's inline features block and azuredevops_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. visibility defaults to private, and public → private is one-way.
  • Process & version control are effectively immutable. Changing work_item_template (process) or version_control after creation forces destroy/recreate. process_template_id is computed (read-only) and surfaced as an output.
  • Pipeline settings: org overrides project. Organization-level pipeline settings win. Setting a field false here when the org enforces it true produces 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 Deny at any level beats an Allow — favour NotSet (inherit) and Deny over blanket Allow.
  • Eventual consistency. Project creation is eventually consistent — dependent resources may briefly 404 right after apply, and a just-created group referenced in permissions may 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.

🧱 Design Principles

  • Type is the contract — deeply-typed object schemas (features and pipeline_settings are typed objects, not loose maps); a malformed input is a plan-time type error.
  • One keystone this + four for_each children — singletons render 0-or-1 via guarded for_each, permissions is a keyed collection — no count.
  • optional with 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_template is intentionally not enum-validated because custom inherited processes are allowed.
  • dynamic + try(x, null) make main.tf a total renderer; optional blocks render only when set.
  • id + project_id are the primary outputs (never object_id).

🚀 Runbook

cd C:\GitHubCode\newazuredevopsmodules\terraform-azuredevops-project
terraform init -backend=false
terraform validate
terraform fmt -check

terraform plan / apply require 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.


🧪 Testing

  • ✅ terraform init -backend=false — provider microsoft/azuredevops v1.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.

💬 Example Output

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 = "..." }

🔍 Troubleshooting

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.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."

About

Terraform module: terraform-azuredevops-project

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages