Fantasy scene of gnomes in cloaks with the Terraform logo standing in a stone circle at night. One reads a spell book, one raises a staff towards a glowing purple scroll showing a plan with checkmarks, and another writes on a tablet. Purple light flows from the circle down into a cavern with several small glowing villages, each marked with the SharePoint logo.

SharePoint as Code: Managing SharePoint Online with Terraform

A Terraform crash course for SharePoint people, and a walkthrough of a new open-source provider that lets you manage sites, lists, permissions, themes and SPFx rollouts as code.

Companion to the community call: this post goes with my talk on the PnP Community Call on October 15, 2026. If you saw the demo and want the details, you’re in the right place.

Be honest: how sure are you that your production tenant’s HR site has the same lists, permission levels and groups as the one in your test tenant? And if someone changed a setting by hand last Tuesday, how would you find out?

For most SharePoint teams, the honest answer is “not very” and “we wouldn’t.” This post is about fixing that with a tool the rest of the cloud world has used for a decade: Terraform. I’ll explain what Terraform is and how it thinks, then introduce a new open-source Terraform provider for SharePoint Online that I’ve built on top of the CLI for Microsoft 365, and walk through a complete example.

The problem with clicks and scripts

There are roughly two ways to provision SharePoint.

Click-ops. You open the SharePoint admin center or site settings and click until it looks right. That’s fine for one site. It’s not fine for ten sites, three environments, and a compliance officer asking who granted “Edit” to an external group.

Scripts. PnP PowerShell and the CLI for Microsoft 365 are fantastic, and I’ve written more deployment scripts with them than I’d like to admit. A script turns the clicks into something repeatable and reviewable. But a script is a list of steps:

New-PnPList -Title "Requests" -Template GenericList
Set-PnPList -Identity "Requests" -EnableVersioning $true

Steps have three weaknesses:

  1. They don’t know what already exists. Run the script a second time and New-PnPList fails because the list is already there. So you add if (-not (Get-PnPList ...)) checks, and then more checks, and soon half the script is checks.
  2. They don’t notice manual changes. If someone turns versioning off in the UI, the script won’t tell you. It only runs when you run it, and only does what you told it to do.
  3. They don’t clean up. If you no longer need a list, deleting the line from your script doesn’t delete the list. You write another script for that.

Over time the result is configuration drift: environments that were meant to be identical slowly stop being identical, and nobody can say how.

Terraform in five minutes

Terraform is an infrastructure-as-code tool from HashiCorp. It’s the de-facto standard for managing Azure, AWS and Google Cloud resources, but also GitHub repositories, Entra ID, DNS records, monitoring dashboards and thousands of other things.

Describe the end result, not the steps

In Terraform you don’t write steps. You write down what you want to exist, in a small configuration language called HCL:

resource "sharepoint_list" "requests" {
  web_url           = "https://contoso.sharepoint.com/sites/hr"
  title             = "Requests"
  base_template     = "GenericList"
  enable_versioning = true
}

This block says: “There should be a list called Requests on the HR site, and it should have versioning turned on.” It doesn’t say “create” or “update.” Working out how to get there is Terraform’s job.

Providers

Terraform itself knows nothing about SharePoint, Azure or anything else. That knowledge lives in providers: plugins that translate “this resource should look like this” into API calls. You declare which providers you need, and Terraform downloads them from the Terraform Registry :

terraform {
  required_providers {
    sharepoint = {
      source  = "fabianhutzli/sharepoint"
      version = "~> 0.1"
    }
  }
}

The sharepoint_ prefix in sharepoint_list tells Terraform which provider owns that resource type.

State

After Terraform creates something, it records it in a state file: “the resource sharepoint_list.requests is the list with ID 1a2b… on this site.” State is Terraform’s memory. On every run it compares three things:

  • What you want: your .tf files
  • What it managed last time: the state
  • What actually exists: it asks the provider to read the real object

From that comparison it works out exactly what needs to change: nothing, an update, a create, or a delete.

Note: State can contain sensitive values. In a team you store it in a shared, locked, encrypted backend (for example an Azure Storage account) rather than on your laptop or in Git.

The workflow: init, plan, apply

terraform init    # download the providers
terraform plan    # dry run: show what would change
terraform apply   # make it so

terraform plan is the step that changes how you work. Before anything touches your tenant, you get a diff:

  # sharepoint_list.requests will be created
  + resource "sharepoint_list" "requests" {
      + title             = "Requests"
      + enable_versioning = true
      ...
    }

Plan: 1 to add, 0 to change, 0 to destroy.

+ means create, ~ means update in place, - means destroy, and -/+ means destroy and re-create (for example when you change a site’s URL). In a team, this plan goes into the pull request, so a colleague reviews the effect on the tenant, not just the code.

Run terraform apply a second time and you get the best message in infrastructure:

No changes. Your infrastructure matches the configuration.

That’s idempotency. Your .tf files are the single source of truth, and you can apply them as often as you like.

Dependencies

Resources can reference each other’s attributes:

resource "sharepoint_site_group" "hr_editors" {
  site_url         = "https://contoso.sharepoint.com/sites/hr"
  name             = "HR Editors"
  permission_level = sharepoint_permission_level.contribute_no_delete.name
}

Because the group refers to the permission level, Terraform knows it has to create the permission level first, and on destroy, delete it last. You never write the order down yourself.

Scripts vs. Terraform at a glance

Script Terraform
You describe the steps the end result
Run it twice errors or duplicates, unless you add guards “No changes”
Manual changes in the UI invisible show up in plan
Preview before changing not really plan, every time
Removing something write a removal script delete the block

This doesn’t make scripts obsolete. They’re still the right tool for one-off jobs, content migrations, and anything that’s an action rather than a state. Terraform is for the configuration you want to keep a certain way.

A Terraform provider for SharePoint Online

Terraform has excellent providers for Azure and Entra ID, but SharePoint itself (sites, lists, permissions, themes, SPFx deployments) has had no real coverage. Teams ended up with Terraform for the Azure part of a solution and a PowerShell script next to it for the SharePoint part.

So I built fabianhutzli/sharepoint . It’s open source (MPL-2.0) and on the official Terraform Registry.

How it works: built on the CLI for Microsoft 365

The most interesting design decision, especially if you’re part of the PnP community: the provider doesn’t call the SharePoint REST or Graph APIs itself. Every create, read, update and delete runs a CLI for Microsoft 365 command under the hood.

terraform  →  SharePoint provider (Go)  →  m365 CLI  →  SharePoint Online

For example, creating the list above runs m365 spo list add …, and refreshing it runs m365 spo list get … --output json. The provider’s job is the Terraform side: the schema, the plan and diff logic, mapping attributes to CLI flags, and turning CLI responses back into state.

Why build it this way? SharePoint’s APIs have many corners: site creation that’s asynchronous, hub registration, app catalog deployments, permission inheritance and more. The CLI for Microsoft 365 has spent years handling those corners, tested by a large community. Building on it meant I could focus on getting the Terraform behaviour right instead of re-implementing SharePoint quirks. So a big thank you to everyone who contributes to the CLI.

The trade-off is that Node.js and the m365 CLI must be installed on the machine that runs Terraform:

npm install -g @pnp/cli-microsoft365

Authentication

The provider uses app-only authentication with a certificate, so there’s no interactive sign-in and it runs unattended in a CI/CD pipeline:

  1. Register an app in Entra ID and grant it the SharePoint application permissions your configuration needs (for example Sites.FullControl.All).
  2. Create a certificate and upload its public key to the app registration.
  3. Configure the provider:
provider "sharepoint" {
  client_id            = var.sharepoint_client_id
  tenant_id            = var.sharepoint_tenant_id   # e.g. contoso.onmicrosoft.com
  certificate_path     = var.sharepoint_certificate_path
  certificate_password = var.sharepoint_certificate_password
}

Every setting can also come from an environment variable (SHAREPOINT_CLIENT_ID, SHAREPOINT_TENANT_ID, …). In pipelines, SHAREPOINT_CERTIFICATE_BASE64 lets you pass the PFX as a secret instead of writing a file to disk.

What you can manage today

Version 0.1 ships 19 resources:

Area Resources
Sites sharepoint_hub_site, sharepoint_associated_site, sharepoint_site
Structure sharepoint_list, sharepoint_list_view, sharepoint_field, sharepoint_navigation_node
Security sharepoint_permission_level, sharepoint_site_group, sharepoint_site_group_member
Branding & provisioning sharepoint_theme, sharepoint_theme_apply, sharepoint_site_script, sharepoint_site_design, sharepoint_site_design_apply
SPFx sharepoint_app_catalog_app, sharepoint_spfx_solution_install, sharepoint_application_customizer
Tenant sharepoint_tenant_settings

Every resource has a documented example on the registry docs .

Walkthrough: an HR site, as code

This is the scenario from the community call demo. We’ll build:

  • a hub site and an HR site associated with it
  • a “Requests” list with versioning
  • a custom permission level that can add and edit but not delete
  • an “HR Editors” site group with that permission level

1. Provider and sites

terraform {
  required_providers {
    sharepoint = {
      source  = "fabianhutzli/sharepoint"
      version = "~> 0.1"
    }
  }
}

provider "sharepoint" {
  client_id            = var.sharepoint_client_id
  tenant_id            = var.sharepoint_tenant_id
  certificate_path     = var.sharepoint_certificate_path
  certificate_password = var.sharepoint_certificate_password
}

locals {
  hostname = replace(var.sharepoint_tenant_id, ".onmicrosoft.com", ".sharepoint.com")
}

resource "sharepoint_hub_site" "intranet" {
  title       = "PnP Demo Hub"
  url         = "https://${local.hostname}/sites/pnp-demo-hub"
  owner       = var.sharepoint_owner
  lcid        = 1033
  site_design = "Showcase"
}

resource "sharepoint_associated_site" "hr" {
  title        = "PnP Demo HR"
  url          = "https://${local.hostname}/sites/pnp-demo-hr"
  owner        = var.sharepoint_owner
  hub_site_url = sharepoint_hub_site.intranet.url
  site_type    = "CommunicationSite"
  site_design  = "Topic"
  lcid         = 1033
}

sharepoint_hub_site creates a communication site and registers it as a hub in one resource. sharepoint_associated_site creates a site and joins it to the hub. Because it references sharepoint_hub_site.intranet.url, Terraform creates the hub first.

Run terraform init and terraform apply. Site creation takes a minute or two, as it does in the UI.

2. List and permissions

resource "sharepoint_list" "requests" {
  web_url           = sharepoint_associated_site.hr.url
  title             = "Requests"
  base_template     = "GenericList"
  enable_versioning = true
  on_quick_launch   = true
}

resource "sharepoint_permission_level" "contribute_no_delete" {
  site_url    = sharepoint_associated_site.hr.url
  name        = "Contribute without Delete"
  description = "Add and edit, but never delete."
  base_permissions = [
    "ViewListItems", "AddListItems", "EditListItems", "OpenItems",
    "ViewVersions", "ViewPages", "Open", "BrowseUserInfo", "UseRemoteAPIs",
  ]
}

resource "sharepoint_site_group" "hr_editors" {
  site_url         = sharepoint_associated_site.hr.url
  name             = "HR Editors"
  permission_level = sharepoint_permission_level.contribute_no_delete.name
}

terraform plan now reports Plan: 4 to add, 0 to change, 0 to destroy. Apply it and refresh the HR site: “Requests” shows up in the quick launch, and “HR Editors” appears under site permissions with the custom permission level.

To put real people in the group, add a sharepoint_site_group_member that references an Entra ID security group by object ID or display name. Membership is then managed in Entra ID, and the SharePoint side stays as code.

3. Catching drift

Now play the colleague who “just quickly” fixes something in production: open the Requests list settings and switch versioning off. Then run:

terraform plan
  # sharepoint_list.requests will be updated in-place
  ~ resource "sharepoint_list" "requests" {
      ~ enable_versioning = false -> true
        id                = "…"
        title             = "Requests"
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Terraform read the real list, compared it with your code, and found the difference. terraform apply puts versioning back on. Run terraform plan on a schedule in your pipeline and you have a drift detector for your tenant. That’s especially useful with sharepoint_tenant_settings, where an unexpected change to the sharing settings is something you want to hear about.

4. Removing things

Delete the sharepoint_list block from your file and run terraform apply: the plan shows 1 to destroy, and the list is removed. No removal script needed.

Beyond the demo: SPFx rollouts

The resource that made me build this provider in the first place is SPFx deployment. A typical pipeline uploads a package to the app catalog, deploys it, installs it on a few sites, and registers an application customizer. With the provider, that’s a handful of resources:

resource "sharepoint_app_catalog_app" "header" {
  file_path = "${path.module}/packages/contoso-header.sppkg"
  deploy    = true
}

…followed by sharepoint_spfx_solution_install and sharepoint_application_customizer for each target site. When a new version of the .sppkg arrives from your build, plan shows the update, and apply rolls it out. The registry docs have the full attribute reference.

Honest caveats

This is version 0.1. I use it for real deployments, but you should know its limits:

  • It needs the m365 CLI. Node.js and @pnp/cli-microsoft365 must be on the machine or build agent that runs Terraform.
  • CLI calls run one at a time. The CLI keeps its sign-in session in a single file, and parallel CLI processes can overwrite each other’s session. To avoid that, the provider runs CLI commands one after another. Large applies are slower than with providers that call APIs directly.
  • Import is limited. Bringing existing SharePoint objects under Terraform management (terraform import) is only supported by sharepoint_spfx_solution_install so far. Today the provider works best for new configuration, or for things you’re happy to re-create.
  • Certificate password exposure. The CLI only accepts the certificate password as a command-line argument during login, so other users on the same machine could briefly see it in the process list. Run Terraform on a dedicated build agent rather than a shared multi-user host.

Try it and get involved

If a resource you need is missing, please open an issue. The provider is built so that a new resource mostly means mapping a CLI command family to a Terraform schema, which makes it a good first contribution if you know the CLI well. And if you try it on your tenant, I’d love to hear how it went.