Guide · Last reviewed: September 2026

Setting up DevOps for Fabric

Everything that has to be switched on, created and connected, on the Fabric side and the Azure side, to get from an empty project to a build that deploys.

This is for a team that has already decided to deploy from a repository. Deciding is a different job and the deployment patterns guide does it. This one is the setup, all of it in one place. The work splits across two consoles, Fabric and Azure, and they are usually owned by different people.

The estate this follows

The example is UK road safety: the collision, casualty and vehicle files the Department for Transport publishes each year, built into a star schema and reported on. It uses most of what Fabric offers:

  • A data pipeline, running the three notebooks in order.
  • Three notebooks, landing the raw files, building the dimensions, and building the fact table.
  • A lakehouse, holding the raw files and the cleaned tables.
  • A warehouse, holding the star schema the semantic model reads from.
  • One semantic model, over the warehouse.
  • Three reports, connected live to that model.

A data engineer builds the pipelines, notebooks, lakehouse and warehouse. A BI developer builds the model and the reports. One person reviews and one approves. Everything on this page applies to all of those items, not only to the semantic model and the reports.

One model with three reports on it is how most Power BI estates are built. A live connection stores the workspace and the model it points at inside the report, so moving a report between environments means rewriting that, once per report. The cross-cutting page has what that costs in each pattern.

The workspaces are WS_RoadSafety_Dev, WS_RoadSafety_Test and WS_RoadSafety_Prod. The environment is a suffix so that one project's three workspaces sort together, which is the rule on the cross-cutting page. Item names inside them carry no environment, and the naming guide covers those.

Who does what

Five roles appear in this setup. One person can hold several of them, and in a small team usually does. Keep the author of a change and the person who approves it separate, because an approval given by the author does not check anything.

RoleOwnsWhat the setup has to give them
The data engineerPipelines, the lakehouse, the warehouse behind the modelSomewhere to run a half-finished notebook that is not where everybody else is looking
The BI developerThe semantic model, and the three reports connected live to itA place to publish a model change before anybody else sees it
The reviewerReads every pull request, and is never its authorThe only control on what reaches the shared workspace
The approverThe gate between Test and productionTo be able to say no, which means the gate sits on the environment rather than in the pipeline
The Fabric tenant administratorTenant settings, and the capacityNeeded once, in week one. Ask early, because it usually takes weeks
The Entra administratorThe app registration and its security groupUsually a different person from the Fabric administrator
The roles in this setup, and what each one needs from it.

Two sides

On the Fabric side you turn on tenant settings, assign a capacity, create the workspaces, and connect one of them to git. Most of this is done once.

On the Azure side you register an application in Entra for the build to run as, create a project and a repository, set a branch policy, and create the environments the approval attaches to.

The two sides meet at the service principal. It needs a permission from each.

The Fabric side

Tenant settings

These live in the admin portal, not in workspace settings, and only a Fabric administrator can change them. Ask for all of them at once.

SettingWhereSet it toWhy it matters
Service principals can call Fabric public APIsAdmin portal, Developer settingsEnabled, for a named security groupRequired for any deployment. It can be granted tenant wide or to a security group. Ask for a group, so the grant stays reviewable
Service principals can create workspaces, connections, and deployment pipelinesAdmin portal, Developer settingsEnabled, for the same group, only if the build creates workspacesOff by default. Without it POST /v1/workspaces fails for the principal while every other call works
Users can synchronize workspace items with their Git repositoriesAdmin portal, Git integrationEnabled, for the group that buildsEnabled by default. Git integration also needs Users can create Fabric items
Users can export items to Git repositories in other geographical locationsAdmin portal, Git integrationOnly if your Azure DevOps organisation is in a different region from the capacityIgnore it until a connection fails for a reason that mentions geography
Service principals can access read-only admin APIsAdmin portal, Admin API settingsOnly if you plan to inventory the tenantNot needed to deploy. It sits next to the one above and is easy to switch on by mistake
The tenant settings to ask a Fabric administrator for.
The Fabric admin portal, Tenant settings, filtered to show Developer settings with two entries: Service principals can create workspaces, connections, and deployment pipelines, and Service principals can call Fabric public APIs. Both read Enabled for the entire organization.
Both of the service principal settings, found by typing part of the name into the filter. These two read Enabled for the entire organization, which is the other option: a named security group is the one to ask for, because it stays reviewable.
The Fabric admin portal, Tenant settings, filtered to show Developer settings with two entries: Service principals can create workspaces, connections, and deployment pipelines, and Service principals can call Fabric public APIs. Both read Enabled for the entire organization.
Ask for these in the first week. Each one is a single toggle, which makes them look quick, and in most organisations they need a change request and a security review first.

The Fabric side in the portal, or as code

Most of these can be done either way. Use the portal the first time, so you can see what the call is doing. Use the API once you are creating the third or fourth workspace, so the names match.

WhatIn the portalAs codeNote
Create a workspaceWorkspaces, New workspace, then name it and pick the capacityPOST /v1/workspaces with capacityId in the bodyOne call. assignToCapacity is for moving an existing workspace later, and needs the Admin role
Add a roleWorkspace, Manage access, Add people or groupsPOST /v1/workspaces/{id}/roleAssignmentsThe body takes a group or a service principal the same way it takes a person
Connect to gitWorkspace settings, Git integration, then pick organisation, project, repository, branch and folderPOST /v1/workspaces/{id}/git/connect, then initializeConnectionNeeds the Admin role. A service principal can only call it with a configured Azure DevOps connection, created first, and has to be a user in the Azure DevOps organisation
Tenant settingsAdmin portal, Tenant settingsPOST /v1/admin/tenantsettings/{name}/updateIt needs a Fabric administrator identity either way, so this lets an administrator script the change rather than removing the wait for one
Every row can be done either way. The tenant settings still need an administrator, whichever route they take.

The calls above are the Fabric REST API. The fab command line tool wraps the same endpoints and reads more easily in a script. Either works.

Capacity

Every workspace in this setup sits on a Fabric capacity. Git integration needs one, and so does everything Fabric does outside Power BI. A Power BI Premium P capacity also works, for Power BI items only.

A trial capacity works for learning this. Do not build the estate you keep on one: when the sixty days end the workspace drops to Pro, every Fabric item in it goes inactive, and you have seven days to move it to a paid capacity before they are removed.

The workspaces

WorkspaceGit connectionWho writes to itNeeds
WS_RoadSafety_DevConnected to mainThe build, and nobody by handCapacity
WS_RoadSafety_TestNothingThe build, from the same commit that reached DevCapacity
WS_RoadSafety_ProdNothingThe build, after the approvalCapacity
Only the Dev workspace connects to git. Test and production receive their contents from the build.

Test and production are connected to nothing. They receive their contents from the build, which reads the same commit that reached Dev and substitutes the values that differ. Connecting each environment to its own branch is documented and is the arrangement to avoid: it removes the build step, and the build is where the connection strings and other per-environment values are swapped.

A fourth kind is optional: one workspace per branch. Somebody who wants to test a change without anybody else seeing it has to publish it somewhere, and that means another workspace on the capacity. In most cases you do not create these yourself: Branch out makes the branch and the workspace together, which is covered below. Name them for the branch rather than the person, because a name that says who made something goes stale the day they change team.

Workspace roles

Assign every one of these to a group, never to a person. It is the same amount of work on the first day, and it means adding or removing somebody later is one change in one place.

RoleWho gets itNote
AdminA group of two, for connecting git and assigning capacityConnecting a workspace to git needs Admin, and so does moving it to another capacity
MemberThe build, only if its provisioning script also assigns rolesAdds the ability to assign workspace roles, which is the one thing here Contributor cannot do
ContributorThe developers, and the buildEnough to create, overwrite and delete items, which is what a deployment does
ViewerNobodyConsumers get the app published from Prod, never a role in the workspace
Contributor is enough to deploy. Member and Admin are each needed for one specific thing.
The Manage access pane of the WS_RoadSafety_Dev workspace in Fabric, with an Add people or groups button, a search box, and one person listed with the Admin role.
A new workspace starts like this, one person as Admin. Add people or groups is where that becomes groups, and where the service principal is added the same way a person would be.
The Manage access pane of the WS_RoadSafety_Dev workspace in Fabric, with an Add people or groups button, a search box, and one person listed with the Admin role.

Connecting Dev to git

In the Dev workspace, workspace settings has a Git integration panel. If it is not there, the tenant setting above is off, or you are not in the group it names.

You give it the Azure DevOps organisation, the project, the repository, the branch and a folder. The branch is main. The folder matters more than it looks: choose one and never change it, because moving it later makes every item look new to the build.

Workspace settings in Fabric with Git integration selected, showing a Connect Git provider and account step offering Azure DevOps or GitHub, and a collapsed Connect Git repository and branch step beneath it.
Provider first, then repository and branch. If this panel is not in workspace settings at all, the tenant setting is off.
Workspace settings in Fabric with Git integration selected, showing a Connect Git provider and account step offering Azure DevOps or GitHub, and a collapsed Connect Git repository and branch step beneath it.
A git sync is not a deployment. Connecting Dev to git means the workspace and the branch can be brought into step with each other. It does not mean a commit has reached Test, and it does not mean anything was tested.

How a change gets into git

Everything above is about workspaces. This is the part that connects a person to them, and it is the step most often left out, which leaves a reader with a pull request they have no way to produce.

Branch out, which does most of this for you

Fabric has a first-class answer to this and it is worth using rather than building your own. In the Dev workspace, Source control, Branch out creates a new git branch from the one that workspace is connected to, creates a workspace connected to that new branch, and syncs the items into it. One action, one dialog.

The new workspace is a branch workspace, which is a real relationship and not just a naming convention. Fabric shows the source workspace as its parent in the workspace tree, puts a link back in the breadcrumbs, and lists it under related branches in Source control. So a team can see who is working on what without asking.

  • It needs capacity. The new workspace has to go somewhere, which is the cost that decides whether every developer gets one.
  • Everything comes across by default. Select items individually narrows it, and then every dependency of what you pick has to come too.
  • Branching out into an existing workspace can lose work. Anything in it that is not committed to git may be deleted. Commit first.
  • Only git-supported items make the trip. Anything Fabric does not yet put in git is not in the new workspace.

From there the developer works in their own workspace, commits to their own branch from Source control, and raises the pull request into main. When it merges, the shared Dev workspace is prompted to update.

A Fabric workspace with the Source control pane open on the right, showing the current branch as main, tabs for Changes, Updates and Related Branches, and one uncommitted report. The item list on the left has a Git status column reading Synced on every row.
The Git status column is the one to watch. Synced means the workspace and the branch agree; anything else is a commit or an update waiting to happen. Related Branches is where the workspaces branched off this one appear.
A Fabric workspace with the Source control pane open on the right, showing the current branch as main, tabs for Changes, Updates and Related Branches, and one uncommitted report. The item list on the left has a Git status column reading Synced on every row.
Switching a branch is not the same as branching out. Switch branch repoints the workspace you are in, and every item in it is overwritten by the new branch's version. An item that exists in the old branch and not the new one is deleted. It also refuses to run while you have uncommitted changes, which is the only thing standing between a careless switch and lost work.

By default only a workspace admin can switch a branch. There is a per-workspace setting, Allow users with at least Contributor role to change Git branch, which opens it to the people who actually build. Decide that one deliberately.

If you automate provisioning instead of using Branch out, the relationship is not created for you. There is a preview Create Workspace Relation API to establish it after your script has made the branch, made the workspace and connected it. Without that call you get workspaces that work and a tree that shows nothing.

What to call a branch, and what the name makes

Branch out asks for two names, the branch and the workspace, and it does not work one out from the other. Type them independently often enough and nobody can tell which workspace belongs to which branch, which matters because the workspace is the thing costing capacity and the branch is the thing under review.

One branch is long-lived and the rest are not. Everything is cut frommain, lives as long as the work does, and goes when it merges. The prefix is the only part a reader needs, because it says what kind of change is arriving before they open it.

BranchLives forWhat it carries
mainLong-livedThe only branch the build deploys from, and the one the branch policy protects. Nobody commits to it directly
feature/<short-name>Until it mergesA new measure, a new page, a new notebook. Merges into main, then the branch and its workspace both go
fix/<short-name>Until it mergesA number is wrong, or something is broken in Test. Same path as a feature, and the name is what says it is not new work
hotfix/<short-name>HoursProduction is broken now. Same path, but the name tells the reviewer and the approver why they are being asked out of hours
Three short-lived kinds and one that stays. The feature and the bug later in this guide are the first two, followed from branch to production.

The workspace name cannot simply repeat the branch name. Workspace names have to be unique across the whole tenant, while a branch name only has to be unique in its repository. So feature/margin-measure is a fine branch and a poor workspace, and the workspace it creates wants the project in front of it: WS_RoadSafety_feature_margin-measure. Same words, in an order that survives a tenant with four other teams in it.

Two limits are worth knowing before someone writes a sentence as a branch name. A branch name can be 244 characters, but the full path of a file in the repository can only be 250, and a Fabric item sits several folders deep inside its own. A long branch name and a deeply nested report is the combination that fails, and it fails at sync rather than at naming.

Branch policies do not come with the branch. Branching out creates the branch, not the settings on it. Anything the source branch had, the new one does not, so whatever your organisation requires has to reach it by a rule that matches names rather than by inheritance. That is the second reason the prefixes are worth keeping to.

The semantic model and the reports

These are authored in Power BI Desktop, and two preview settings decide whether any of the review in this guide is possible. In Options, Preview features, turn onPower BI Project (.pbip) save and the TMDL andPBIR formats. Saving without them produces a .pbix, which is a single binary file: it cannot be diffed, cannot be reviewed, and turns every merge into a choice between two whole files.

Connect live to the warehouse in the developer's own workspace, build the model, build the reports against it, and save as .pbip into the working copy of the branch. Publish to the workspace to see it running, and commit the project files from the branch.

The first commit after a Desktop round trip is noisy. Desktop rewrites files it did not semantically change, so the diff is larger than the change. Commit that noise on its own, before the real change, so the pull request a reviewer reads is the change and not the reformatting.

The Azure side

What a service principal is

Two things share the name. An application registration is the definition of an application in Entra: its identity, its secrets, what it is allowed to ask for. A service principal is the instance of that application inside a particular tenant, and it is the thing that holds permissions. The portal creates both at once. The command line does not, which is why the table below has two commands. When somebody says "give the service principal access", the service principal is the object they mean.

It is an account a build signs in with. It has an identity, it can be given access to a workspace the same way a person can, and it can sign in with nobody present.

A build can run as you instead, and it will work. There are two reasons not to. It stops the day you change role or leave, and it can do everything you can do. If a developer can deploy to production by running the pipeline as themselves, the approval on the environment is not stopping anything.

Creating it

In Entra, register an application, then create a client secret or upload a certificate. Use a certificate if your organisation allows it. A secret expires, and the error only says authentication failed. Note the tenant id and the client id, which are not secret, and put the secret straight into a key vault.

Then create a security group and put the application in it. A tenant setting can be enabled for the whole organisation or for named groups. Ask for a group, so the grant stays reviewable, and ask for it at the same time as the registration.

The Overview blade of an app registration in the Azure portal, showing the Essentials panel with Display name, Application client ID, Object ID and Directory tenant ID on the left, and Client credentials with an Add a certificate or secret link on the right. The identifying values are replaced with labels.
Two of these four are what a pipeline needs: the client id and the tenant id. Client credentials, top right, is where the secret or certificate is created. The values are blanked here because they identify a real tenant.
The Overview blade of an app registration in the Azure portal, showing the Essentials panel with Display name, Application client ID, Object ID and Directory tenant ID on the left, and Client credentials with an Add a certificate or secret link on the right. The identifying values are replaced with labels.

How the pipeline signs in as it

In Azure DevOps this is a service connection: a stored link to the service principal that a pipeline task can use by name. The pipeline never sees the secret. It runs a task under the service connection, and the script inside picks up the signed-in identity from the environment.

Microsoft's own reference for this does exactly that. Its pipeline runs an Azure CLI task naming a service connection, and the Python inside asks for AzureCliCredential(), which is the credential that task has already established. There is no secret anywhere in the repository or the pipeline definition.

This is where a Fabric-only team gets stuck. An Azure Resource Manager service connection is scoped to an Azure subscription, and the Azure CLI task selects that subscription before it runs your script. If the service principal has no role on the subscription, the task fails at az account set with "the subscription doesn't exist in cloud AzureCloud", before anything of yours has run. Fabric needs no subscription at all, so this is an obstacle created entirely by the way the task works.

You have three ways past it. Give the principal a role on a subscription, which is the least appealing because it grants Azure access for a Fabric job. Use workload identity federation, which Microsoft now recommends over a secret and which removes the expiry problem below. Or skip the service connection and sign in yourself:

- script: |
    az login --service-principal -u "$(SP_CLIENT_ID)" -p "$SP_SECRET" \
      --tenant "$(SP_TENANT_ID)" --allow-no-subscriptions --output none
    python -u deploy/deploy_workspace.py --workspace-id "$(WORKSPACE_DEV)" ...
  env:
    SP_SECRET: $(SP_CLIENT_SECRET)

That last one keeps the secret in a variable group rather than a service connection, which is a real trade: one fewer Azure dependency, one more place a secret lives. For a team with no Azure subscription to hand, it is often the only one of the three that is available.

Where the secret lives

Anything the pipeline needs beyond the service connection goes in a key vault it reads at run time. Not in a pipeline variable, even a secret one, and not in the repository in any form. A vault gives one place to rotate it and one place to revoke it. Set the expiry reminder the day you create it.

The project and the repository

One project, one repository, and three environments named for the three workspaces. Do not create a repository per workspace. The point of this pattern is that one commit is deployed to three places, and three repositories give you three separate histories that cannot be compared.

The pipeline, and the order to create things in

Point a new pipeline at the YAML in the repository and run it once. The order matters here, because two of these steps depend on something the previous one creates.

  1. The repository, with the pipeline definition committed to it.
  2. The service connection, which the pipeline names.
  3. A variable group holding the three workspace ids and the service connection name. The provisioning script prints the ids.
  4. The pipeline, created from the YAML.
  5. Permission, granted once per protected resource. A pipeline cannot use a service connection or an environment until somebody authorises it, and the first run stops and waits rather than failing.
  6. The first run, which creates the environments.
  7. The branch policy, because build validation has to name a pipeline that exists.
  8. The approval on the Prod environment, which now exists to attach it to.

Steps six and seven are the ones that catch people. A branch policy cannot point at a pipeline that has never run, and an approval cannot attach to an environment that has not been created.

A stage implicitly depends on the stage before it. If your first stage only runs for pull requests, and your next stage's condition begins withsucceeded(), then on a merge the first stage is skipped, andsucceeded() is false for a dependency that was skipped rather than run. Every later stage skips in turn and the run reports success. Give the first deploying stage dependsOn: [].

That one is worth knowing beyond this setup, because a pipeline that skipped everything looks exactly like a pipeline that deployed everything: same green tick in the run list, same badge, same notification. The only tell is the stage cards saying skipped, or checking afterwards that the target actually changed. It is the same argument as checking a value rather than a status, one level further out.

What lives in project settings

Four of the things this guide asks for are in Azure DevOps project settings rather than anywhere you would look first, and they are easy to hunt for.

WhatWhere in project settingsWhy it is there and not elsewhere
Service connectionsPipelines, Service connectionsIt belongs to the project, not to one pipeline, so several pipelines can use it and one person can revoke it
Agent pools and parallel jobsPipelines, Agent poolsA new organisation has no free Microsoft-hosted parallelism until you request it, and the first pipeline run queues forever with no error
Repository policies and permissionsRepos, RepositoriesBranch policies are set per branch here, and who may bypass them is set alongside
Pipeline settings and retentionPipelines, SettingsWhere you stop new pipelines being created as classic release definitions, and how long runs are kept
Project settings, reached from the bottom left of any project page.

Branch policy

A branch policy is how main stops being a branch anybody can write to. It matters more here than in ordinary code, because the shared Dev workspace is downstream of main. A bad merge reaches the model that everybody is working against, within minutes, rather than waiting to be noticed.

PolicySet it toWhy
Require a minimum number of reviewersOne, with Allow requestors to approve their own changes untickedWithout it anybody can merge their own change
Check for comment resolutionOnAn unanswered review comment is the most common way a known problem ships
Limit merge typesSquashOne commit per merged change keeps the history readable
Build validationOn, pointing at a build that validates rather than deploysCatches a broken item definition before the shared workspace rather than after
Branch policies to set on main.
A reviewer cannot be the author. Azure DevOps will let somebody approve their own pull request if the policy permits it, and in a team of two under pressure somebody always will. Exclude the requester. Decide in advance what happens when only one person is available, and write it down.

Not every Fabric item can be read in a diff.

ItemWhat is in the repositoryCan a reviewer read the change?
NotebookThe code, as a .py fileYes. It is an ordinary code review
Semantic modelTMDL, which is text, with a file per table. Measures sit inside their table’s fileYes. A new measure is a few readable lines
Data pipelineJSON describing the activitiesPartly. A changed setting is findable, a rearranged pipeline is not
ReportA folder of JSON, including where every visual sitsNo. Moving one visual produces a diff nobody can read
WarehouseA SQL database project, one .sql file per table, view and procedureYes. A column change is an ordinary DDL diff. The feature is in preview
LakehouseMetadata and shortcut definitions. No tables and no filesNot applicable. Nothing about its contents is in the repository
Notebooks, models and warehouse schema can be reviewed properly. Reports and pipelines can be checked for which file changed.

So a review of a notebook, a model or a warehouse change is a real review. A review of a report or a pipeline change is a check that the file which should have changed is the one that did, and that nothing else moved. Say which of the two you are asking for.

Two people on one model

TMDL puts each table in its own file, so two people adding measures to different tables merge without touching each other. Two measures on the same table is a real git conflict in a text file, resolvable by reading it. Two people changing the same report is not resolvable that way: take one version and redo the other change.

That is the practical reason for one model owner even on a team where everybody can edit. The reports can have many authors, and the model is easier with one.

Environments and approvals

An environment in Azure DevOps is a target for a pipeline stage. Approvals attach to the environment, not to the pipeline file.

An approval in the pipeline file can be removed by whoever edits the pipeline. An approval on the environment cannot. Microsoft documents the mechanism under approvals and checks(opens in a new tab).

For this estate: three environments, no approval on Dev, none on Test, and one on Prod naming the approver rather than a group that contains the whole team.

The Approvals and checks tab of a Prod environment in Azure DevOps, showing one check in the execution order: All approvers must approve, of type Approvals, with a thirty day timeout and an approver pictured.
The breadcrumb is the point: Pipelines, Environments, Prod. The check belongs to the environment, so editing the pipeline file cannot remove it.
The Approvals and checks tab of a Prod environment in Azure DevOps, showing one check in the execution order: All approvers must approve, of type Approvals, with a thirty day timeout and an approver pictured.

The Azure side in the portal, or as code

The same applies on this side. Two of these have no CLI command and are REST or portal only.

WhatIn the portalAs codeNote
Register an applicationEntra, App registrations, New registrationaz ad app create --display-name <name>, then az ad sp create --id <appId>The second command is what creates the service principal. The first only defines the application
Create a secretThe registration, Certificates and secrets, New client secretaz ad app credential reset --id <appId> --appendBoth print the secret once and never again. Without --append the command deletes every existing secret first
Create a repositoryAzure DevOps, Repos, New repositoryaz repos create --name <name>Needs the azure-devops extension, which is one install
Set a branch policyRepos, Branches, the branch menu, Branch policiesaz repos policy approver-count createOne command per policy, and each takes the repository and branch as arguments
Create a service connectionProject settings, Service connections, New, Azure Resource Manageraz devops service-endpoint azurerm createThe CLI takes the secret from AZURE_DEVOPS_EXT_AZURE_RM_SERVICE_PRINCIPAL_KEY, not a flag, and needs an Azure subscription to scope to
Create an environment and its approvalPipelines, Environments, New environment, then Approvals and checksEnvironments API, then POST .../_apis/pipelines/checks/configurationsBoth work. The checks API is on a preview api-version and has no CLI command, and the approver id is an Azure DevOps identity rather than an Entra one
All of these can be scripted, including the approval.

The two permissions people confuse

The service principal needs a grant from each side, and having one without the other produces errors that never name the missing half.

The tenant setting lets it call the API at all. Without it, every call fails at authentication, whatever workspace it was aimed at. Workspace membership lets it write to a particular workspace. Without that, calls succeed and then report that the workspace does not exist, which sends people to check the workspace id for an afternoon.

So the principal is added as a member of all three workspaces, the same way a person would be. The tenant setting is granted once for the whole tenant. Workspace membership is granted once per workspace.

Two mechanisms, and why that is not a contradiction

The cross-cutting page says to pick one way of configuring per environment and stick to it. This setup uses two, and the split is by what a value is rather than by taste.

parameter.yml handles the things that are baked into an item definition at authoring time: the lakehouse a notebook attached to, the endpoint a model reads from. Those are substituted as the item is published, before anything runs. A variable library handles values the code reads while running, and its value set is chosen by the environment name the deploy was given, which is why the file is calledtest.json. Put a running value in the first, or a definition value in the second, and you have the mess the other page warns about.

The three files, line by line

Three files do the work, and none of them is long. What follows is what each one is for and which parts are decisions rather than boilerplate.

requirements.txt

Two lines, and the only interesting thing is the lower bound.

fabric-cicd>=1.3.0
azure-identity>=1.19.0

fabric-cicd is the library that reads a folder of Fabric item definitions and publishes them. Pin a lower bound rather than an exact version, so a fix arrives without a pull request, and raise it deliberately when a release changes behaviour. azure-identity is what turns the signed-in session into a token the library can use.

deploy_workspace.py

The whole script is one object and one call. Everything else is argument parsing.

target = FabricWorkspace(
    workspace_id=args.workspace_id,        # which workspace this run writes to
    environment=args.environment,          # picks the value set and parameter column
    repository_directory=str(ROOT),        # the folder holding the item definitions
    item_type_in_scope=args.item_types,    # nothing outside this list is touched
    token_credential=AzureCliCredential(), # whoever the step signed in as
)
publish_all_items(target)
WhatWhat it doesWhy it is a decision
workspace_idThe workspace this run writes toIt comes from the variable group, so the same file deploys to three places
environmentNames the environment: dev, test or prodIt picks which column of parameter.yml is used and which variable library value set is active. The value set file is called test.json because this argument says test
repository_directoryThe folder holding the item definitionsIt is whatever folder git integration writes into, and it has to match the folder set in the workspace’s git connection
item_type_in_scopeThe item types this deploy may touchAnything not named is left alone. A type missing here is the usual reason something never reaches production
Four arguments, three of which change per environment and one of which changes per estate.

azure-pipelines.yml, and the variables

The pipeline is four stages of the same shape. What is worth understanding is where the values come from, because that is the part that is invisible in the file.

VariableWhere it is definedWhat it holds
WORKSPACE_DEV, _TEST, _PRODVariable groupThe three workspace ids, printed by the provisioning script
SP_CLIENT_ID, SP_TENANT_IDVariable groupThe principal and the tenant it signs in against. Neither is secret
SP_CLIENT_SECRETVariable group, marked secretThe one value that is. Marked secret, so it is masked in logs and has to be mapped into a step explicitly
ITEM_TYPESThe pipeline filePer pipeline rather than per estate, and the same for every stage, so it lives in the file where you can see it next to the stages
Everything in the variable group is per estate. Everything in the file is per pipeline.

$(NAME) is substituted by Azure DevOps before the script runs, so by the time bash sees the line the value is already in it. That is fine for a workspace id and wrong for a secret, which is why the secret is handled differently.

A secret variable is not available to a script unless you map it.Mark a variable secret and $(SP_CLIENT_SECRET) stops resolving in the script body. You have to pass it through env: and read it as an ordinary environment variable, which is why the step below has an env: block and the others do not.
- script: |
    az login --service-principal -u "$(SP_CLIENT_ID)" -p "$SP_SECRET" \
      --tenant "$(SP_TENANT_ID)" --allow-no-subscriptions --output none
    python -u .deploy/deploy_workspace.py \
      --workspace-id "$(WORKSPACE_TEST)" --environment test \
      --item-types $(ITEM_TYPES)
  displayName: Deploy to Test
  env:
    SP_SECRET: $(SP_CLIENT_SECRET)   # a secret has to be mapped in, not inlined

The pipeline reads the group with - group: fabric-devops at the top, and every stage inherits it. A pipeline is not allowed to use a group until somebody authorises it, which is the permission step in the ordering above.

A release, end to end

Walk this through once before the first real release.

The change is small on purpose. The model gains a measure for killed or seriously injured casualties, and one of the three reports gains a card showing it.

A release, by role. The BI developer changes the model on a branch in a workspace only they can see, then opens a pull request and can no longer merge it. The reviewer reads it and says so. The build deploys the merged commit to Dev, then the same commit to Test. The approver approves or does not, on the environment rather than in the pipeline. Only after that gate does the build deploy that commit to production and rebind every report to production’s model. The BI developer then checks the anchor, the number that differs per environment. The reviewerThe BI developerThe buildThe approverChange ona branchOpen a pullrequestRead it,and say soDeploy toDev, TestApprove,or do notDeploy toproductionCheck theanchoronly after the gateData NovaSave SVG

The developer changes the model on a branch and publishes it to their own workspace if they have one. They open a pull request and can no longer merge it, which is where the branch policy starts doing its job. From here the change needs a second person.

The reviewer reads it, properly for the model and as a sanity check for the report. The merge triggers the build, and the build writes to Dev, so the contents of Dev match main exactly. If somebody has been editing Dev by hand, their changes are overwritten here.

The same commit goes to Test, with different values substituted. It is the same commit, not a rebuild of the same source. The first time a commit reaches an environment, somebody also runs the data pipeline there, binds the model's connection to that environment's endpoint, and sets the refresh schedule and any row-level security membership. None of those travel with a deployment, in any pattern, and the cross-cutting checklist is the list to work through. The approver approves, or does not.

The build deploys to production and rebinds the reports, all three of them, because the binding lives in each report. Test with all three, not with one. Then somebody opens a report and checks a value. Every stage above can return green while serving the wrong environment's data.

Here is that release happening: Test green and production waiting, the approver seeing the instruction the check was created with, the deploy naming each item as it publishes, and all three environments on the same commit.

What you seeWhat it isWhat fixes it
The build is green and production shows yesterday’s numbersA report still bound to the model in the workspace it was authored againstRebind the report to the target model, then check a value
Workspace not found, and the workspace is right thereThe principal has the tenant setting and no role in that workspaceAdd it as a member of the workspace
The Git integration panel is missing from workspace settingsThe tenant setting for git is off, or the group does not include youAdmin portal, not workspace settings
Everything works for a month, then authentication failsThe client secret expiredThe reminder set the day it was created
Four common failures after a release, and what fixes each.

Three things that actually happen

The release above is the clean case. These three are what the week is really made of, and they take the same path with different amounts of it skipped.

A feature: a new measure and a new page

Branch out from Dev, which gives you a branch and a workspace. Add the measure to the model in Desktop, add the page to one report, publish to your own workspace and look at it. Commit both from Source control, open the pull request, and the reviewer reads the TMDL properly and checks that only the report you meant to touch has changed.

On merge it goes to Dev, then Test. Somebody opens the report in Test and looks at the new page against the anchor value, because that is the only step that catches a deployment which succeeded into the wrong place. Then the approver lets it into production, or does not.

A bug: the number is wrong in production

Reproduce it in Test first, not in production and not on your own machine. If Test has the same commit as production, and it does if nobody has deployed since, then a number that is right in Test and wrong in production is not a code problem at all. It is configuration, data, or a deployment that did not do what it said.

If Test is wrong too, it is the model. Branch out, fix, and take the ordinary path. The temptation is to fix it directly in the production workspace, and the reason not to is that the next deployment overwrites it and the bug comes back with no explanation.

Decide in advance what a hotfix may skip. Skipping the review and skipping the approval have very different consequences, and a team that has not agreed will skip whichever step the most senior person present finds most annoying. Write it down while nothing is on fire.

Testing: what the pipeline can check and what it cannot

There is no unit test framework for a semantic model, so this is a shorter list than it would be for application code, and worth knowing exactly.

CheckWhere it runsWhat it catches
The pull request buildOn every pull request, before mergeA broken item definition, a parameter.yml that does not resolve, a file that is not valid JSON or TMDL
The deploy itselfEach stageAnything the service rejects: a missing dependency, an item that cannot be created, a principal without the role it needs
Somebody opening the reportIn Test, before the approvalEverything the machine cannot see. A measure that returns the wrong number is a correct deployment
The anchor valueAfter each deploy, in the targetA deployment that succeeded into the wrong environment. Every status above can be green while this is wrong
The first two are automatic. The third is a person, and the fourth is the only one that catches a deployment into the wrong environment.

The anchor check is the one to build first and the one teams skip. Everything above it tells you the deployment ran. Only a value tells you it ran against the right place.

Who owns what, once it is deployed

Every Fabric item has an owner, and it is not a label. The owner is the identity Fabric uses to reach that item's connections and credentials when nobody is watching. A lakehouse's SQL analytics endpoint runs as the lakehouse's owner. A refresh runs on the credentials the owner stored. So when that person leaves, orsimply does not sign in for ninety days, things stop working, and they stop at the next refresh rather than at the moment anything changed.

The good news about the setup in this guide is that it gets this right without being asked. The build creates the items in Test and production, so the service principal owns them, and a service principal does not leave, go on holiday or fail to sign in. That is worth knowing on purpose rather than by luck, because it is the single reason those two workspaces do not decay.

How it arrivedOwner the first timeOwner after that
Created by the build, in gitThe service principal owns itUnchanged. A redeploy overwrites the definition, not the owner
Semantic model, deployment pipelineWhoever ran the deployment owns itUnchanged
Dataflow, deployment pipelineWhoever ran the deployment owns itUnchanged
Paginated report, deployment pipelineWhoever ran the deployment owns itChanges every time. The last person to deploy owns it
Ownership is decided when an item is created and mostly never revisited. The paginated report is the exception that moves every deployment.

Three things are never copied by a deployment, whichever mechanism you use: the refresh schedule, the data source credentials and role assignments. They belong to the target and they survive every release. That sounds like a gap and is actually the thing that makes this manageable: you set them once in Test and once in production, and no release undoes them.

Fixing it after the fact

This is where it gets awkward, and the awkwardness is the argument for the build owning things from the start.

ItemHow ownership movesWhat to know
Lakehouse, EventstreamThe assign identity APIThe only ownership change with an API. Takes a service principal or a managed identity, and carries to child items
Semantic model, report, dataflowThe item's own settingsThe existing take over, unchanged. A person, never a service principal
Notebook, pipeline, warehouse and the restItem settings, About, Take overOne item at a time, by hand. A pipeline needs one more small edit and save afterwards or Last Modified By stays on whoever left
Mirrored databasesNothingOwnership cannot be changed at all. If the owner goes, the mirror is recreated
Four different answers for four kinds of item, and only the first one can be automated.
Take over cannot hand an item to a service principal. It gives the item to the person who clicked it. So an item a developer created by hand in production can only become build-owned by being recreated through the pipeline, unless it is a lakehouse or an eventstream, where the API can do it. This is the practical reasonnothing should be created by hand in Test or production, rather than any argument about discipline.
Take over does not follow dependencies. A pipeline with a notebook activity: taking over the pipeline leaves the notebook owned by whoever left. It reaches an item's children, not the items it merely calls.

To see where you stand before somebody's last day rather than after it, ask for the owner with the item itself: GET /v1/workspaces/{workspace}/items?include=defaultIdentity. Note that only lakehouses and eventstreams answer today, which is the same set the API can fix. Everything else still has to be read in the portal.

Watching it once it runs

The build tells you its own runs went green. It cannot tell you about anything that happened outside it, and that is usually the question being asked. Three questions, three different places, and none of them is the one place you would expect.

What went to production, and who allowed it

This one is free and already working. Azure DevOps keeps a deployment record per environment, and each entry carries the commit, the identity that deployed it and the approval that let it through. Open the production environment rather than the pipeline: the pipeline shows runs, the environment shows what is actually in the place. That is the answer to almost every audit question you will be asked, and nobody has to configure it.

Whether anything reached Fabric without going through the build

The uncomfortable one. A person editing a report directly in Test produces no pipeline event at all, because no pipeline ran. It shows up in Fabric, in the activity log, which records git and deployment operations by name alongside everything else.

OperationWhat happenedWhen it is worth a look
CommitToGitSomebody pushed a workspace change into a branchNormal from a branch workspace. From Test or production it should not happen at all
UpdateFromGitA workspace pulled a branch inNormal in Dev after a merge. Anywhere else, somebody bypassed the build
BranchOutInGitA branch workspace was createdHow you see capacity being taken, and by whom
SwitchBranchInGitA workspace was repointed at another branchThe one that silently overwrites every item in the workspace
UndoGitSomebody threw away an uncommitted changeUsually harmless, occasionally the reason a day of work is missing
ConnectToGit, DisconnectFromGitA workspace joined or left source controlDisconnecting also destroys the branch workspace relationships
DeployAlmPipelineA deployment pipeline pushed to a stageOnly relevant if somebody is running the other mechanism alongside this one
The operations worth filtering for. Most are ordinary in Dev and mean something has gone around the build anywhere else.
The activity log holds 28 days, and answers one day per request. Start and end have to fall in the same UTC day, so anything useful means a loop and somewhere to put the output. Events can also take up to an hour to appear, so it is not the place to look at something that just happened. For longer than 28 days the same events are in the Purview audit log, where your licence decides how far back it goes.

Whether the estate is still healthy

Refresh failures, capacity, items erroring: Fabric's own monitoring, not Azure DevOps'. Workspace monitoring is the current answer, and it is worth knowing exactly what it is before turning it on. It creates a read-only Eventhouse in the workspace, you query it with KQL or SQL, it keeps 30 days, and it bills to your capacity like any other Eventhouse.

Workspace monitoring does not cover any of this guide. It collects from semantic models, pipelines, eventhouses, eventstreams, mirrored databases and a few others. Git operations and deployments are not in it, so it answers the third question and neither of the first two. It also cannot run alongside log analytics in the same workspace.

The part to do before you need it

Azure DevOps has its own audit log, for changes to the setup rather than to the data: branch policy edits, permission changes, things being deleted. It is the record that answers who removed the approval. Three things about it are worth knowing on day one rather than on the day you need it.

  • It is off by default. Nothing is being recorded until somebody turns it on in organisation settings, and it cannot be turned on retrospectively.
  • It keeps 90 days, then deletes. Export, or stream it somewhere, if you need longer.
  • It needs an Entra-backed organisation, and it is still in preview.

That is the whole practical point of this section. Both logs are short and one of them is not running. The question you will eventually be asked is about something that happened months ago, and the answer depends on a decision taken now.

Day two

Somebody joins. Add them to the group, and run the provisioning script if they need a workspace. That is the whole list.

Somebody leaves. Workspace roles and the build need nothing, if the groups and the service principal were done as above. Two things always need checking: a cloud connection bound with somebody's own identity dies with their account, and so does a refresh schedule set up under it. Both fail silently, at the next refresh rather than at the moment of the change.

Production breaks and needs a fix now. A hotfix takes the same path: branch, pull request, review, build, approve. Agree in advance which step may be skipped when it is urgent, and write it down. A hotfix is deployed quickly, to production, so the check at the end matters more here than anywhere else.

You want to rehearse a release. Run the deploy script locally with --dry-run, which resolves and transforms everything and writes nothing. That is also what the pull request build runs. To rehearse for real, point it at a fourth throwaway workspace rather than at Test, and keep the tenant guard the provisioning script uses, because the command line tool holds whichever tenant was last signed into.

A release has to be taken back. Not in production: nothing there remembers what it held last week, because it holds a copy of each item and the history lives in the repository. Going back a version means taking the commit you want and putting it through the same path. There is no undo button. Stopping a release before it goes out is much less work than reversing one.

What this does not solve

Six things a team runs into that nothing above fixes. They are here because finding them yourself, three months in, is worse than reading them now.

The problemWhy the setup above does not solve itWhat people do
Lakehouse and warehouse data does not deployOnly definitions are in git. A deployed workspace has the tables and none of the rowsRun the data pipeline in each environment after the first deploy, or shortcut production data into Test read-only
Two people editing one reportA report is a folder of JSON. Git can merge it and the result is not a report anybody wantsOne owner per report. If two people must, they take turns and say so
Deleting an itemA deploy publishes what is in the repository and leaves everything else alone, so a deleted item stays in the target foreverunpublish_all_orphan_items, which is a deliberate call and deletes things. Turn it on knowing that
The first environment has no values to substituteparameter.yml needs the target’s ids, and the target does not exist until the first deploy creates itLet fabric-cicd resolve item ids itself. Only list what it cannot know, such as a SQL endpoint
Nobody notices a broken scheduled refreshRefresh schedules do not deploy, and a refresh that fails after a release fails quietly at 6amAlerting on refresh failure, set up per environment, by hand, once
Someone edits the shared Dev workspace directlyNothing prevents it. The next build overwrites them and the change disappears with no errorTake Contributor off Dev and leave the build as the only writer, once the team is used to branching out
None of these has a tidy answer. Each has a usual answer.

The solution this builds

Everything on this page is built out as a working solution: three notebooks, a data pipeline, a lakehouse, a warehouse of views, a variable library with a value set per environment, a parameter.yml, an Azure DevOps pipeline that validates on a pull request and deploys on a merge, and a script that provisions the three workspaces.

It runs on UK road safety collision data, published by the Department for Transport under the Open Government Licence. No key and no sign-up, so the whole thing can be run against your own tenant. Three files a year, joined on the collision reference, built into a star schema at one row per casualty.

The repository is private while this guide is in draft. The link goes in when it opens up. It is fabric-devops-setup, and it holds the notebooks, the pipeline, the deploy scripts and the provisioning.

Sources

Microsoft published the code from its Build 2025 session on this.

Read its pipeline for the authentication and the deploy script, and not for the branching. It triggers on a dev branch and a main branch, one workspace each, with no Test stage and no approval, which is the arrangement this guide argues against.