Writing/Fabric
Article

How Azure Automation Cut Microsoft Fabric Cost by 78%

Four schedules, one Fabric capacity, and a simple decision to stop paying for idle compute.

When we first provisioned Microsoft Fabric, the simplest operating model was obvious: leave the capacity running.

Technically, that worked.

Financially, it made very little sense.

The data platform was not processing data continuously. Most of the week, there was nothing for the Fabric capacity to do.

Anonymization note

Operational schedules, business-specific identifiers, resource names, source-system details, and environment-specific configuration have been generalized. The architecture, technical decisions, implementation patterns, and measured outcomes are preserved.

During recurring weekday processing windows, an orchestration pipeline ingests source data into the bronze layer, then triggers the downstream transformations that build the silver and gold layers. The complete bronze → silver → gold process normally finishes within a relatively short processing window.

A separate recurring weekend workload follows a similar end-to-end pattern for another data source. The source data first lands in bronze, moves through transformation and validation work in silver, and finishes in a curated gold layer ready for reporting and analytics.

Outside those defined processing windows, no scheduled data workloads require the Fabric capacity to remain active.

There is also an additional seasonal workload. Because it operates only during part of the year, I deliberately kept it outside the permanent capacity schedule. When that processing season starts, its compute window can be enabled temporarily rather than paying for unused capacity throughout the year.

Workload pattern Scheduled processing
Recurring weekdays Complete bronze → silver → gold processing chain
Recurring weekend window Separate complete bronze → silver → gold processing chain
Outside processing windows No scheduled data processing

Yet the Fabric capacity itself could remain available 24 hours a day, 168 hours every week.

The workloads needed only a fraction of that.

Why keep the capacity running for 168 hours when the workloads only need it for a few?

Instead of trying to optimize individual notebooks or shave seconds from pipeline execution, I moved the optimization one level higher.

I made the capacity follow the workloads.

The solution used Azure Automation, a system-assigned managed identity, a least-privilege Azure RBAC role, one PowerShell runbook, and a small set of schedules.

168 → 9.5 Approx. scheduled hours per week
94% Less scheduled runtime
~78% Estimated capacity cost reduction
0 Data workloads redesigned
Architecture diagram showing Azure Automation using a PowerShell runbook, system-assigned managed identity and least-privilege RBAC to resume and pause Microsoft Fabric capacity around scheduled bronze, silver and gold data workloads.
Azure Automation controls when the Fabric capacity is available. The existing bronze → silver → gold data workloads remain unchanged.

The problem was not the pipelines

Cost optimization conversations often start inside the workload.

Can the notebook run faster? Can transformations be simplified? Can refresh frequency be reduced? Can the pipeline use fewer activities?

Those are all valid questions. But they were not the biggest opportunity here.

The pipelines already had reasonably short execution windows. The much larger inefficiency existed outside them.

Imagine a restaurant that serves customers for two hours every evening but keeps the entire kitchen operating 24 hours a day. Making the chef work 10% faster might help.

Closing the kitchen when there are no customers helps much more.

The Fabric capacity had essentially the same problem.

Fabric knew how to execute the pipelines. It did not know when the organization actually needed the compute.

We had to provide that intelligence ourselves.

First, I mapped the real compute windows

Before automating anything, I documented when compute was actually required.

The recurring weekday workload had a predictable processing window.

Because it orchestrates the complete bronze → silver → gold chain, I did not want the capacity to resume at exactly the same moment the first workload was scheduled to begin.

The platform should already be available before processing starts.

So I added a startup buffer.

Resume capacity Compute becomes available before processing begins.
Processing begins The bronze → silver → gold orchestration starts.
Processing completes The normal data-processing chain finishes.
Pause capacity A completion buffer is intentionally preserved.

The weekend workload required a larger processing window because it included additional scheduled stages.

That produced a small set of recurring automation events.

Schedule Pattern Timing Action
Weekday start Recurring weekdays Before processing window Resume
Weekday end Recurring weekdays After completion buffer Pause
Weekend start Recurring weekend window Before processing window Resume
Weekend end Recurring weekend window After completion buffer Pause
I deliberately did not optimize the schedule down to the minute

Some additional capacity time is a better trade-off than suspending a production workload that happens to run longer than usual. The goal was safe savings, not a theoretically perfect utilization number.

Automation without stored credentials

Once the compute windows were clear, the next question was authentication.

Something has to call Azure and tell the Fabric capacity to pause or resume.

The easy implementation would be to put credentials into a script or run the process under an account with broad Azure permissions.

I didn't want either.

Instead, I created an Azure Automation Account and enabled its system-assigned managed identity.

That gave the Automation Account its own identity in Microsoft Entra ID.

No username. No password. No client secret.

The automation authenticates to Azure as itself.

But authentication was only half of the problem.

The identity still needed authorization to operate the capacity.

I didn't give the automation Contributor access

The runbook had one responsibility: manage the lifecycle of one Fabric capacity.

Giving it Contributor access across an entire subscription would have worked technically, but it would also have granted the automation far more authority than it needed.

Instead, I created a custom Azure RBAC role containing only the Fabric capacity operations required by the process:

Azure RBAC · custom role actions
Microsoft.Fabric/capacities/read
Microsoft.Fabric/capacities/write
Microsoft.Fabric/capacities/suspend/action
Microsoft.Fabric/capacities/resume/action

I assigned that role to the Automation Account's managed identity at the individual Fabric capacity resource scope.

The automation did not receive broad control over every Azure resource.

It received permission to operate the resource it was responsible for.

01 Azure Automation Runs the schedules and the runbook.
→
02 Managed Identity Authenticates without stored secrets.
→
03 Custom RBAC Limits control to required capacity operations.
→
04 Fabric Capacity Resumes for workloads, pauses when idle.
The identity chain. Each link holds only the authority the next one needs.

Cost automation should not create a new security problem.

One runbook controls both actions

I could have created separate runbooks for pause and resume.

I deliberately didn't.

Both operations act on the same resource and differ only by the requested action, so I created one parameterized PowerShell runbook.

PowerShell · runbook parameters
param (
    [Parameter(Mandatory = $true)]
    [ValidateSet("Pause", "Resume")]
    [string]$Action
)

The schedules call the same runbook with either Action = Resume or Action = Pause.

At a high level, the runbook performs six steps.

  1. Authenticate Use the Automation Account's managed identity.
  2. Read state Check the current state of the Fabric capacity.
  3. Compare Determine whether a state change is necessary.
  4. Act Call either the suspend or resume operation.
  5. Verify Poll the capacity until the requested state is reached.
  6. Fail clearly Raise an error if the transition does not complete.

The state check is worth having.

Suppose somebody manually resumes the capacity before the scheduled processing window.

When the automation later runs, instead of blindly submitting another resume operation, the runbook checks whether the capacity is already active.

If it is, there is nothing to do.

The same applies to pause.

In other words, the automation is designed to be idempotent.

The core control logic

PowerShell · runbook core logic
# Configuration values intentionally anonymized

if ($Action -eq "Pause") {
    $apiAction    = "suspend"
    $desiredState = "Paused"
}
else {
    $apiAction    = "resume"
    $desiredState = "Active"
}

# Avoid unnecessary operations
if ($currentState -eq $desiredState) {
    Write-Output "Capacity is already in the desired state."
    exit 0
}

# Submit the requested capacity operation
Invoke-WebRequest `
    -Uri $actionUri `
    -Method POST `
    -Headers $armHeaders `
    -ContentType "application/json"

The production runbook goes one step further.

After the API accepts the request, it continues polling the Fabric resource until the expected state is actually reached.

An accepted API request is not the same thing as a completed state transition.

I tested both directions before scheduling anything

I did not attach the runbook to production schedules immediately.

First, I manually tested the pause operation using the Azure Automation test pane.

Automation test pane output terminal
Authenticating using Automation Account managed identity...

Fabric capacity: [redacted]
Current state: Active
Requested action: Pause

Submitting Pause request...

Request accepted. HTTP status: 202

State check 1 : Pausing
State check 2 : Paused

SUCCESS: Fabric capacity is now Paused.

That single test validated several components of the design at once.

I then ran the same test with Action = Resume and verified that the capacity returned to Active.

Only after both directions worked manually did I publish the runbook and connect the schedules.

A scheduled automation that has never been tested manually is just a scheduled failure waiting for the right time.

Then I attached the schedules

Once the runbook was proven, the remaining implementation was surprisingly small.

A small set of schedules calls the same runbook with different recurrence patterns and parameters.

01 Weekday resume Recurring weekday processing Action = Resume
02 Weekday pause Recurring weekday processing Action = Pause
03 Weekend resume Recurring weekend processing Action = Resume
04 Weekend pause Recurring weekend processing Action = Pause

The schedules use the environment's local time-zone configuration rather than manually calculated UTC timestamps.

That keeps capacity windows aligned with workload schedules through daylight-saving changes without maintaining separate seasonal schedules.

The normal processing flow

Resume Bronze Silver Gold Pause

The weekend workload follows the same principle, with a longer capacity window around its scheduled processing.

168 hours became approximately 9.5

This was the number that made the decision particularly interesting.

A week contains 168 hours.

The permanent scheduled capacity windows total approximately:

Window Approximate weekly runtime
Recurring weekday windows ~6.25 hours
Recurring weekend window ~3.25 hours
Total scheduled runtime ~9.5 hours / week

Scheduled availability therefore changed from:

Before 168 h Capacity scheduled as always available
→
After ~9.5 h Capacity aligned to workload windows
 
168h
 
~9.5h

That represents approximately a 94% reduction in scheduled capacity runtime.

I intentionally do not describe that as a 94% reduction in the Azure bill.

Cloud billing is rarely that simple.

Storage remains. Actual workload consumption matters. Platform accounting matters. Other services can also contribute to overall cost.

So rather than assuming that a 94% reduction in scheduled runtime would translate directly into a 94% reduction in cost, I used a deliberately conservative financial model.

The model allows for the reality that not every Fabric-related cost disappears when capacity is paused, and that actual workload consumption can vary.

Even after allowing for that additional consumption and variability, the expected reduction in Fabric capacity cost is approximately 78%.

About the 78% figure

The 78% figure is a conservative modeled estimate based on scheduled capacity availability rather than a claim based on a full year of invoices. As sufficient billing history accumulates, the model can be compared with actual Azure Cost Management data.

The biggest saving required no pipeline optimization

What I like most about this change is what we did not have to do.

The pipelines continue to operate exactly as they did before.

The optimization happened around them.

That is an important distinction in cloud architecture.

Sometimes the biggest cost improvement is not hidden inside SQL, Spark, Python, a notebook, or a data pipeline.

Sometimes the resource simply should not be running.

The risk: what if a workload runs long?

Scheduled shutdown introduces one obvious operational risk.

What happens if a workload that normally completes within its expected window is still processing when the scheduled pause event arrives?

At that point, a hard capacity pause stops being a cost optimization.

It becomes an availability problem.

This is why the schedules include completion buffers instead of pausing the capacity immediately after the expected processing time.

Today, workload execution is predictable enough that a fixed window is a reasonable trade-off.

But I would not consider this design permanently finished.

If processing durations become more variable, the next iteration would be workload-aware suspension.

Scheduled pause boundary reached
↓
Are relevant workloads still running?
↓
Yes Wait and check again.
No Pause the capacity.

For the current workload volume, that additional complexity is not yet justified.

I do not want to build a sophisticated control plane for a problem that a small set of schedules already solves reliably.

Seasonal workloads stay seasonal

An additional workload becomes active only during part of the year.

I intentionally did not expand the permanent weekly compute window to accommodate it.

When that processing season starts, the relevant capacity schedule can be temporarily added or modified.

When the season ends, the additional compute window can be removed.

This prevents the infrastructure schedule from gradually accumulating permanent runtime for workloads that are active only occasionally.

Capacity should follow demand.

What I will monitor from here

The automation is working, but the cost story should now be validated against real operational and billing data.

01 Actual cost

Compare monthly Fabric spending with the original always-on estimate.

02 Execution duration

Watch whether processing chains begin approaching their configured pause boundaries.

03 Automation failures

A failed resume matters because downstream workload schedules may still attempt to execute.

The automation that saves money has now become part of the operating platform.

It deserves monitoring like any other production dependency.

The lesson wasn't “use Azure Automation”

Azure Automation happened to be the mechanism.

The more important lesson was architectural.

When we think about cloud cost, we often start with choosing a smaller capacity, optimizing queries, reducing storage, or rewriting workloads.

Those are useful tools.

But there is an even more basic question worth asking first:

Does this resource need to be active right now?

The Microsoft Fabric workloads already had clearly defined operating windows.

The compute did not.

A small set of schedules fixed that mismatch.

The result was a 94% reduction in scheduled capacity runtime and an estimated 78% reduction in capacity cost, while preserving the existing end-to-end data architecture.

What this did not require

No pipeline redesign. No workload-frequency reduction. No stored automation credentials. No broad Contributor access.

Just a small amount of orchestration around infrastructure that previously stayed on because nothing had told it when to stop.

Sometimes cost optimization means making the workload faster. In this case, it meant something simpler: when the data platform has finished working, let the compute sleep.