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:
- They don’t know what already exists. Run the script a second time and
New-PnPListfails because the list is already there. So you addif (-not (Get-PnPList ...))checks, and then more checks, and soon half the script is checks. - 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.
- 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
.tffiles - 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:
- Register an app in Entra ID and grant it the SharePoint application permissions your configuration needs (for example
Sites.FullControl.All). - Create a certificate and upload its public key to the app registration.
- 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
m365CLI. Node.js and@pnp/cli-microsoft365must 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 bysharepoint_spfx_solution_installso 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
- Registry: registry.terraform.io/providers/fabianhutzli/sharepoint
- Source, issues and discussions: github.com/fabianhutzli/terraform-provider-sharepoint
- CLI for Microsoft 365: pnp.github.io/cli-microsoft365
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.