Archiboard

Set C · Cloud & Operations

The cost sheet: what a tenant actually costs you

Cost per tenant is an allocation, not a measurement. The rules of the allocation are a product decision, and finance will treat the first number you give them as the truth.

In the autumn of 2023 a finance director asked me what a tenant cost us per month on Azure. I said about forty euro. I had divided the subscription invoice by the number of tenants, which felt reasonable in the corridor. Two weeks later I had a proper sheet and the median tenant cost 112 euro, the smallest cost nine, and the largest, one customer whose nightly integration moved more data on its own than the rest of the platform put together, cost 1,900. The forty was true in the sense that a mean is true, and useless for every decision she wanted to make with it.

The number you can get from Azure is what a resource cost. The number finance wants is what a tenant cost, and no resource in a shared stamp knows which tenant it served. Getting from the first to the second is an allocation, and the allocation rules are yours to choose. Choose them on purpose, write them down, and keep them for at least a year, because a cost per tenant that changes method every quarter tells nobody anything.

Tags before anything else

Cost Management can slice by subscription, resource group and tag, so the allocation starts with a tag on every resource that says what it is for. Ours are four: stamp (eu1, eu2, or shared), tier (platform for things that serve every stamp, floor for things a stamp pays regardless of load, variable for things that grow with tenants), component, and env. They are set in the Bicep module and nowhere else, so a resource without them is a resource somebody created by hand, which is its own finding.

param stampName string
param env string

var tags = {
  stamp: stampName
  env: env
  tier: 'floor'
  component: 'compute'
}

resource plan 'Microsoft.Web/serverfarms@2024-04-01' = {
  name: 'asp-app-${stampName}'
  location: location
  tags: tags
  sku: { name: 'P1v3', capacity: 2 }
}

Two things the documentation says that I learned by being surprised. Tags do not inherit from the resource group; if you want that, you need an Azure Policy to copy them down, and we have one for the rare resource created outside Bicep. And not every resource type supports tags at all, so for those the fallback is the resource group, which is why every stamp has its own group and the shared platform has one too.

Getting the numbers out

Do not do this in the portal. Cost Management exports write a file to a storage account every day, and since 2025 the sensible dataset is FOCUS, which combines actual and amortized cost into one schema and can be written as Parquet. A nightly .NET worker reads the partitions listed in the manifest, keeps the columns it needs (date, resource id, the four tags, effective cost) and writes them into a table that the allocation runs against. The export takes up to a day to settle, so the sheet always runs two days behind; finance did not mind, and I stopped apologising for it.

Use amortized cost, or FOCUS's effective cost, not the actual column. If you bought a reservation for the App Service plans, the actual dataset shows a large purchase in one month and zero for the next eleven, and a tenant who joined in the purchase month looks like a disaster.

Shared, floor and variable

Every line of cost lands in one of three buckets, and the three buckets are allocated differently. That is the whole model.

The shared platform is everything with stamp = shared: Front Door, the tenant directory, App Configuration, the shared Log Analytics workspace, the identity service. It is divided equally across all active tenants. You could weight it by revenue and I have argued about it; equal is defensible and nobody has to explain a weighting formula to an auditor.

The floor of a stamp is what the stamp costs with zero tenants on it. The App Service plan or the Container Apps environment at its minimum, the Service Bus namespace, the elastic pool's reserved capacity, the stamp's own Application Insights. In our eu1 stamp it is about 4,200 euro a month, and it is the same on the 1st of the month with 240 tenants as it would be with three. This is the bucket that made my forty euro wrong.

Variable cost grows with use: elastic pool overage, storage, egress, the per-tenant queues, PDF rendering compute. Each variable line gets a driver, which is a measured quantity per tenant that we believe moves that cost. Database size drives the pool overage. Blob bytes drive storage. Documents rendered drive the worker's vCPU seconds. The drivers come from our own tables, never from Application Insights, because Application Insights samples and a billing driver cannot.

Cost allocation tree from the subscription down to individual tenants subscription: 18,400 / month shared platform: 2,100 stamp eu1: 9,700 stamp eu2: 6,600 fixed floor: 4,200 variable: 5,500 divided by design capacity 250 split by measured drivers tenant A: 96 tenant B: 41 tenant C: 312 the hatched floor is divided by what the stamp was built for, not by the tenants on it today
Fig. 1. Three buckets, three rules. Shared cost is split equally, the floor is split by design capacity, and the variable lines follow measured drivers down to each tenant.

The formula and its traps

Per tenant per month, the cost is the shared platform divided by active tenants, plus the floor of the tenant's stamp divided by the stamp's design capacity, plus each variable line multiplied by that tenant's share of its driver. The worker computes it in a few lines and writes one row per tenant per month.

decimal CostFor(Tenant t, MonthlyCosts m)
{
    var stamp = m.Stamps[t.StampId];

    var shared   = m.SharedPlatform / m.ActiveTenants;
    var floor    = stamp.FixedFloor / stamp.DesignCapacity;   // 250, not today's head count
    var variable = stamp.VariableLines.Sum(line => line.Amount * line.ShareFor(t));

    return shared + floor + variable;
}

The design capacity is the trap I fell into with the second stamp. In its first month eu2 held three tenants and a floor of 4,100 euro, and dividing by three made each of them cost 1,400 before they had rendered a single document. Dividing by the 250 the stamp was built for gives 16 euro, which is what those tenants will cost once the stamp fills, and the remaining 4,050 goes on a separate line called "growth capacity" that the company chose to buy. Finance understood that immediately. It is the same reasoning they apply to an empty office floor.

Other traps are smaller. Currency, if any resource is billed in dollars. Months with 31 days against months with 28, which makes February look like a good month for no reason; normalise to a 30-day month or show both. And the cm-resource-parent tag, which Cost Management uses to group child resources, deserves a look if you have resources whose parent is not obvious from the tags.

What to show finance

One page. For each tenant: floor share, variable, total, and the plan they are on. Below that, three totals: shared platform, growth capacity, and everything allocated. Then gross margin per plan, which is the number that changes pricing conversations, and which they cannot compute without you because only you know which tenant is on which plan and which stamp.

Do not show them the tree. The tree is for you, so that when the total moves you can find which bucket moved and why. When the variable line for eu1 jumped 30 percent in one month, the tree led to a single tenant whose nightly integration had started re-uploading every document, and that was a support ticket, not a pricing problem.

The trade-off I would make today

I would keep three buckets and three rules and refuse a fourth, because every refinement makes the number more accurate and less explainable, and the explanation is what finance is actually buying. I would divide the floor by design capacity and carry the growth line separately, even though it means the sum of the tenants never equals the invoice. And I would build the sheet a month before anyone asks, so that the first number anyone hears is one I can defend.

Drawn from