Contoso's main branch is already protected by policies, but one of them points at nothing: build validation requires a pipeline to give the go-ahead before merging, and that pipeline does not exist yet. Without it, the other policies check formalities — there are two approvals, there is a linked work item — but nobody has verified the most elementary thing: that the code builds and that the tests pass.
This lesson builds that verifier. Azure Pipelines runs, on every proposed change and every merge, the same repeatable sequence of restore, build, test, analyze and package. The result is an artifact — the exact package that will later be deployed — and a binary verdict within a few minutes. Here we deal only with building and verifying; taking that artifact to Azure is the next lesson.
Contents
- What continuous integration is and what problem it removes
- Agents, pools and their real costs
- YAML pipelines versus classic UI ones
- Anatomy of an
azure-pipelines.yml, line by line - The essential tasks and dependency caching
- Variables, variable groups and Key Vault
- Reusable pipeline templates
- Pull request validation and job matrices
- Artifacts, failure diagnosis and the status badge
- Common Mistakes and Tips
- Exercises
- Conclusion
- What continuous integration is and what problem it removes
Continuous integration means that every change is integrated with everybody else's work and verified automatically, several times a day. It is not "having a build server": it is a discipline in which everyone's code is constantly brought together instead of piling up in separate corners.
The problem it removes has a name: integration hell. Diego works for three weeks on the cabin map while a colleague refactors the fare model; each tests on their own laptop and everything works. When it is brought together, conflicts appear, signatures do not match and a bug shows up that nobody can attribute — right on the Friday of the release. The cost of integrating grows non-linearly with how long you take to do it, hence the counterintuitive conclusion: if integrating hurts, do it more often.
| Requirement | What it means | How Contoso meets it |
|---|---|---|
| A shared repository | A single source of truth | Azure Repos (05-02) |
| Automated build | Nobody builds by hand to validate | This pipeline |
| Automated tests | The verdict is objective, not an opinion | Unit tests with coverage |
| Fast feedback | Under 10 minutes | Caching, parallelism, fast tests |
The cultural rule that holds it up: if the main build breaks, fixing it is the team's number one priority. A broken build that is tolerated for days turns the pipeline into noise everybody ignores.
- Agents, pools and their real costs
An agent is the machine that runs the steps. There are two kinds, and choosing wrong costs money or flat out stops you deploying:
| Microsoft-hosted agents | Self-hosted agents | |
|---|---|---|
| Who maintains them | Microsoft | You |
| State between runs | Always a fresh, clean machine | Persists: fast caches, but also junk |
| Preinstalled software | A broad catalog (SDKs, Docker, Node) | Whatever you install |
Access to vnet-contoso-pro |
No | Yes |
| Maximum time per job | 60 min (free) / 360 min (paid) | No limit |
| Cost | Free minutes and then per parallel job | The VM plus its maintenance |
Real cost warning: a private project gets 1 hosted parallel job with 1,800 free minutes a month. Once those are gone, runs queue until the following month or you have to buy additional parallel jobs (around $40/month each). Public projects get 10 free parallel jobs with unlimited minutes, which makes making a repository public tempting: do not do it to save money. With self-hosted agents the first parallel job is free and the following ones cost around $15/month, but you pay for the virtual machine and its maintenance.
Contoso needs both. The build pipeline (this lesson) runs on hosted agents: it touches nothing private and benefits from always-current images. The deployment pipeline (05-04) that acts against sql-contoso-reservas-pro needs to be inside vnet-contoso-pro, because that server has only been reachable through pe-sql-reservas since we locked it down in 02-05: there you need a self-hosted agent in snet-gestion, grouped into the pool-contoso-privado pool.
# Install a self-hosted agent on a Linux VM inside snet-gestion
mkdir ~/agent && cd ~/agent
curl -O https://vstsagentpackage.azureedge.net/agent/3.243.1/vsts-agent-linux-x64-3.243.1.tar.gz
tar zxvf vsts-agent-linux-x64-3.243.1.tar.gz
./config.sh --unattended --url https://dev.azure.com/contoso-airlines \
--auth pat --token $AGENT_PAT --acceptTeeEula \
--pool pool-contoso-privado --agent agente-contoso-01
sudo ./svc.sh install && sudo ./svc.sh start # As a service, to survive rebootsThe agent VM must authenticate against Azure with a managed identity, not with stored credentials: the same principle as module 4.
- YAML pipelines versus classic UI ones
| YAML | Classic (graphical UI) | |
|---|---|---|
| Where the definition lives | In the repository, next to the code | In the Azure DevOps database |
| Versioning and rollback | With Git: history and branches | Not versioned |
| Review | In the pull request, like code | Nobody reviews a click |
| Reuse | Templates | Task groups |
| Status | Recommended | Legacy, no new features |
The decisive argument is the first one: if the pipeline lives in the repository, a change to how you build goes through the same review as a code change, is recorded and can be reverted. With classic, somebody changes a step on a Tuesday afternoon, the build starts behaving differently and there is no history to consult. Contoso uses YAML exclusively.
- Anatomy of an
azure-pipelines.yml, line by line
azure-pipelines.yml, line by lineThis is the contoso-reservas-ci pipeline, at the root of the repository:
# Build number for each run; available as $(Build.BuildNumber).
# Including the version is what will let us version packages in 05-05.
name: 2.5.$(Date:yyyyMMdd)$(Rev:.r)
trigger: # Which PUSHES start the pipeline
branches:
include: [ main, releases/* ]
paths:
exclude: [ docs/*, README.md ] # Do not burn minutes on documentation changes
pr: # Which PULL REQUESTS start it: this is what hooks into
branches: # the build validation policy from 05-02
include: [ main ]
schedules: # Nightly build: catches breakage that does not come
- cron: "0 2 * * *" # from your code (an updated dependency, a new image)
displayName: Nightly build
branches: { include: [ main ] }
always: true # Run even if there have been no changes
pool:
vmImage: ubuntu-latest # Microsoft-hosted agent
variables:
- group: vg-contoso-reservas-comun # Shared variable group (section 6)
- name: solutionPath
value: 'src/Contoso.Reservas.sln'
stages:
- stage: build
jobs:
- job: compile
timeoutInMinutes: 20 # Cuts the run short if something hangs
steps:
- checkout: self
fetchDepth: 1 # Shallow clone: faster
- task: UseDotNet@2 # Do not depend on what the image happens to ship
inputs: { packageType: sdk, version: '8.0.x' }
- script: dotnet restore $(solutionPath)
displayName: Restore dependencies
- script: dotnet build $(solutionPath) -c Release --no-restore -warnaserror
displayName: Build
- script: |
dotnet test $(solutionPath) -c Release --no-build \
--logger trx --collect:"XPlat Code Coverage" \
--results-directory $(Agent.TempDirectory)/tests
displayName: Run unit tests
- task: PublishTestResults@2
condition: succeededOrFailed() # Publish even if they failed:
inputs: # that is when you most want to see them
testResultsFormat: VSTest
testResultsFiles: '$(Agent.TempDirectory)/tests/**/*.trx'
failTaskOnFailedTests: true # Without this the pipeline stays green
- task: PublishCodeCoverageResults@2 # even when the tests fail
condition: succeededOrFailed()
inputs:
summaryFileLocation: '$(Agent.TempDirectory)/tests/**/coverage.cobertura.xml'
- script: |
dotnet publish src/Contoso.Reservas.Web/Contoso.Reservas.Web.csproj \
-c Release --no-build -o $(Build.ArtifactStagingDirectory)/web
displayName: Prepare the deployable package
- publish: $(Build.ArtifactStagingDirectory)/web
artifact: web-reservas # What 05-04 will take as isOn the structure: trigger and pr are the two push triggers (trigger: none disables automatic starting, typical in deployment pipelines). The stages → jobs → steps hierarchy means that a stage is a logical phase and can carry approvals, a job runs in its entirety on one agent and several can run in parallel, and a step is an individual action. The difference between task and script is that a task is a reusable component with named inputs and a version (@2), whereas a script is a shell command: use tasks when they bring something real — publishing results, authenticating — and scripts when the command is clear, because they are more readable and portable. And condition: succeededOrFailed() forces a step to run even if the previous one failed.
- The essential tasks and dependency caching
Restore always explicitly, without relying on what "was already" on the machine. Build with -warnaserror, which turns warnings into errors; without it they pile up in the hundreds and stop being read. Test while publishing results and coverage, with failTaskOnFailedTests: true — and beware of turning coverage into a target: demanding 90% produces tests that walk through code without checking anything. Publish the artifact, which enshrines the principle of build once, deploy many times: the binary validated in development is exactly the same, bit for bit, as the one that reaches production; rebuilding per environment introduces differences that are impossible to trace.
What is still missing is static analysis, which looks for bugs and vulnerabilities before they exist at runtime, and caching, which brings restore time down from minutes to seconds:
# The cache key is derived from the operating system and the hash of the
# project files: if a dependency changes, the key changes and a stale
# cache is not reused. A fixed key serves junk forever.
- task: Cache@2
inputs:
key: 'nuget | "$(Agent.OS)" | **/*.csproj'
restoreKeys: 'nuget | "$(Agent.OS)"'
path: $(NUGET_PACKAGES)
- script: |
dotnet format --verify-no-changes # Agreed style
dotnet list package --vulnerable --include-transitive
displayName: Style and vulnerable dependency analysisNever cache build outputs, only downloaded dependencies.
- Variables, variable groups and Key Vault
| Where | What for | Visibility |
|---|---|---|
variables in the YAML |
Non-sensitive pipeline values | Public in the repository |
| Variable group in the library | Values shared by several pipelines | Project-wide, with permissions |
| Group linked to Key Vault | Secrets | Never visible; resolved at run time |
A secret is never written in the YAML, which is in the repository and in every clone; we already saw in 05-02 what a committed secret costs. The correct way is to link a variable group to kv-contoso-pro:
# A normal group, for non-sensitive values
az pipelines variable-group create --name vg-contoso-reservas-comun \
--variables entorno=comun proyecto=contoso-reservas centro-coste=CC-1042 \
--authorize false
# Group linked to kv-contoso-pro: the VALUES stay in the vault and
# the service connection only needs the "Key Vault Secrets User" role
az pipelines variable-group create --name vg-contoso-secretos-pro \
--variables pasarela-pago-clave="" token-meteo="" --authorize falsevariables:
- group: vg-contoso-secretos-pro # Its values are read from kv-contoso-pro
steps:
- script: ./scripts/prueba-integracion.sh
env:
TOKEN_METEO: $(token-meteo) # Secrets are NOT injected into the environment
# automatically: you map them on purposeThree details that save you grief. Secret variables are not exposed automatically as environment variables; the explicit env: mapping is a deliberate protection. Azure Pipelines masks secrets in the logs, but it is not infallible: if your script prints the value transformed (base64-encoded, for instance), the masking will not recognize it. And --authorize false forces you to authorize which pipelines may use the group, which is least privilege applied to build secrets: the pipeline of a test application has no business being able to read the payment gateway key.
- Reusable pipeline templates
Contoso has four repositories that build the same way. Copying the YAML into each one means four places to update when the SDK changes. Templates solve that. In contoso-infra, the file plantillas/compilar-dotnet.yml:
parameters: # Typed: if a required one is missing, it will not even start
- { name: solutionPath, type: string }
- { name: artifactName, type: string }
- { name: sdkVersion, type: string, default: '8.0.x' }
- { name: runTests, type: boolean, default: true }
steps:
- task: UseDotNet@2
inputs: { packageType: sdk, version: ${{ parameters.sdkVersion }} }
- script: dotnet restore ${{ parameters.solutionPath }}
- script: dotnet build ${{ parameters.solutionPath }} -c Release --no-restore -warnaserror
# Expansion-time condition: if runTests is false, these steps do not
# even exist in the generated pipeline
- ${{ if eq(parameters.runTests, true) }}:
- script: dotnet test ${{ parameters.solutionPath }} -c Release --no-build --logger trx
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs: { testResultsFormat: VSTest, testResultsFiles: '**/*.trx', failTaskOnFailedTests: true }
- publish: $(Build.ArtifactStagingDirectory)
artifact: ${{ parameters.artifactName }}Usage from the contoso-api-disponibilidad repository:
resources:
repositories:
- repository: plantillas
type: git
name: contoso-reservas/contoso-infra # project/repository
ref: refs/tags/plantillas-v1.2 # PINNED to a tag, not to main
steps:
- template: plantillas/compilar-dotnet.yml@plantillas
parameters:
solutionPath: 'src/Contoso.Api.sln'
artifactName: 'api-disponibilidad'The ref pinned to a tag stops a change to the template from breaking all four pipelines at once: the update happens by bumping the tag, deliberately. And there is a stricter variant, extends, in which the child pipeline cannot add arbitrary steps, only fill in the gaps that were designed for it:
extends:
template: plantillas/pipeline-seguro.yml@plantillas
parameters:
buildSteps:
- script: dotnet build src/Contoso.Reservas.sln -c Releaseextends is the mechanism a platform team uses to guarantee that no pipeline can skip the security analysis, however much somebody edits their YAML. It is the delivery equivalent of the policies in 04-06: you do not rely on goodwill, you make it structurally impossible.
- Pull request validation and job matrices
With the pr trigger defined and the branch policy from 05-02 pointing at this pipeline, the loop closes:
graph LR
A["Diego pushes<br/>feature/1842"] --> B["Opens a<br/>pull request"]
B --> C["pr trigger:<br/>CI runs"]
C --> D{"Does it build and<br/>do tests pass?"}
D -->|No| E["Red status:<br/>merge blocked"]
D -->|Yes| F["Green + 2 approvals<br/>+ linked work item"]
F --> G["Squash onto main"]
G --> H["trigger:<br/>CI on main"]
H --> I["web-reservas artifact<br/>ready for 05-04"]
When you need to verify against several configurations, a matrix generates one job per combination:
- job: compatibility
strategy:
matrix:
linux_net8: { agentImage: 'ubuntu-latest', sdkVersion: '8.0.x' }
windows_net8: { agentImage: 'windows-latest', sdkVersion: '8.0.x' }
linux_net9: { agentImage: 'ubuntu-latest', sdkVersion: '9.0.x' }
maxParallel: 2 # Limited by the parallel jobs you have paid for
pool:
vmImage: $(agentImage)
steps:
- task: UseDotNet@2
inputs: { packageType: sdk, version: $(sdkVersion) }
- script: dotnet test src/Contoso.Reservas.slnmaxParallel matters for money: with a single parallel job, a nine-combination matrix does not run nine times faster, it runs in single file and consumes nine times the minutes.
- Artifacts, failure diagnosis and the status badge
Pipeline artifacts (publish/download) |
Build artifacts (PublishBuildArtifacts@1) |
|
|---|---|---|
| Performance | Much faster, with deduplication | Slow with many files |
| Storage | Optimized | Full copy |
| Status | Recommended | Legacy |
- publish: $(Build.ArtifactStagingDirectory)/web # Publish in the build stage
artifact: web-reservas
- download: current # Retrieve it in another stage
artifact: web-reservasWhen a pipeline fails, the diagnostic order that saves hours: (1) read the log of the step that failed, not the summary — the real error is usually 30 lines above the last red line; (2) turn on diagnostic logs by setting the system.debug variable to true on a re-run, which shows variable resolution and real paths, where the problem usually lives; (3) reproduce locally by running exactly the commands in the YAML, because 80% of "pipeline-only" failures are environment differences — a variable that exists on your laptop, a file ignored by .gitignore that is present on your machine, or the casing of a name that matters on Linux and not on Windows; and (4) check permissions if the failure mentions authorization: the service connection or the variable group is probably not authorized for this pipeline.
And the status badge in the README.md, which gives immediate visibility into whether main is healthy:
[](https://dev.azure.com/contoso-airlines/contoso-reservas/_build/latest?definitionId=12&branchName=main)Common Mistakes and Tips
- Building in every environment. It breaks the build once, deploy many times principle: the production binary is no longer the one that was tested. Build once, publish the artifact and reuse it.
- Writing secrets in the YAML. They are in the repository and in every clone. A variable group linked to
kv-contoso-pro, always. - 40-minute pipelines. If the verdict takes longer than a coffee, people stop waiting for it and carry on working on unverified code. Split, cache and parallelize until you are under 10 minutes.
- Tolerating a broken build on
main, or living with flaky tests. As soon as red is normal, the pipeline stops meaning anything and the team learns to re-run without looking. continueOnError: truefor convenience. It turns a step into decoration. Use it only while you tune the threshold of a new tool, and with an expiry date.- Templates pointing at
main. One change breaks every pipeline simultaneously. Pin to a tag and version your templates. - Tip: set
timeoutInMinuteson every job. A hung job burns billable minutes up to the 60 or 360 limit. - Tip: exclude
docs/*andREADME.mdfrom the trigger. It is one minute of work that saves agent minutes every month.
Exercises
Exercise 1: read a pipeline and spot its problems
A colleague proposes this pipeline for Contoso Miles:
trigger:
branches: { include: ['*'] }
pool: { vmImage: ubuntu-latest }
variables:
apiKey: 'ak_millas_9f2b7c1d4e'
steps:
- script: dotnet build src/Millas.sln
- script: dotnet test src/Millas.sln
continueOnError: true
- script: dotnet publish src/Millas.Web -o output- Identify at least five problems.
- List the specific changes you would make to fix them.
- Which one is a security incident, and what has to be done beyond fixing the file?
Exercise 2: agents and cost
Contoso adds three pipelines: one builds the website, another the API and another runs integration tests against sql-contoso-reservas-dev, which has a private endpoint. All three run about 20 times a day, 12 minutes each.
- What kind of agent does each pipeline need, and why?
- Estimate the monthly hosted-minute consumption and say whether it fits in the free tier.
- Propose two ways of cutting the spend without giving up verification.
Exercise 3: designing for reuse
The four repositories build .NET almost identically, but contoso-infra has no unit tests (it is Bicep templates) and contoso-modelos publishes a package instead of a web application. Security requires that no pipeline be able to skip the vulnerable dependency analysis.
- Would you use
templateorextends? Justify it against the security requirement. - Sketch out the template's parameters.
- How do you stop updating it from breaking all four pipelines at once?
Solutions
Solution 1:
- (a) A secret in the YAML:
apiKeyis in the repository. (b)triggeron all branches, which builds every working branch and burns minutes. (c) Theprtrigger is missing, so the build validation policy has nothing to hook into. (d)continueOnError: trueon the tests: the pipeline passes even when they fail. (e) Neither results nor coverage are published. (f) No artifact is published at all: there is nothing to deploy afterwards. (g)UseDotNetpinning the SDK version is missing, so it depends on whatever the agent image happens to ship. (h) NotimeoutInMinutesand no readabledisplayNamevalues. triggerlimited tomainpluspr: [main];UseDotNet@2with8.0.x; an explicitdotnet restore; build with-warnaserror;dotnet testwith--logger trx --collect:"XPlat Code Coverage";PublishTestResults@2withfailTaskOnFailedTests: trueandcondition: succeededOrFailed(); coverage publishing;dotnet publishto$(Build.ArtifactStagingDirectory)followed by- publish:with an artifact name; the key replaced by a variable group linked tokv-contoso-proand mapped withenv:; andtimeoutInMinutes: 20.- The secret. Beyond taking it out of the file it has to be rotated: it is in the Git history and in the logs of every previous run. Then store the new value in the vault and check whether it was misused. Just as in 05-02, deleting is not enough.
Solution 2:
- Website and API, hosted agents: they need no access to anything private and benefit from a clean, maintained machine. The integration tests, a self-hosted agent in
pool-contoso-privadoinsidesnet-gestion, becausesql-contoso-reservas-devis only reachable through its private endpoint and a hosted agent has no route to it. The alternative would be managed scale set agents inside the virtual network. - The two hosted pipelines add up to 2 × 20 × 12 = 480 minutes a day, roughly 10,500 a month over 22 working days. The free tier is 1,800, so it is far exceeded; and with a single parallel job the runs would also queue up. You would have to buy additional parallel jobs (~$40/month each).
- (a) Dependency caching and a shallow clone, to halve those 12 minutes. (b) Exclude irrelevant paths from the trigger and do not build on every push to working branches, but on the pull request. (c) Reserve the full matrix and the slow tests for the nightly build, leaving only the essentials in the fast loop. (d) Compare the cost of a self-hosted agent VM with that of the parallel jobs, which at this volume may well favor the VM.
Solution 3:
extends. Withtemplatethe child pipeline includes the template but can add steps, reorder them or simply not call it; withextendsit is obliged to build on top of it and can only fill in the gaps designed for it, so the dependency analysis cannot be skipped by editing the YAML. It is the difference between a recommendation and a rule, the same logic as the policies in 04-06.solutionPath(string, required),artifactName(string),sdkVersion(string, default8.0.x),runTests(boolean,falseforcontoso-infra),outputType(weborpackage, to distinguishcontoso-modelos) andadditionalSteps(stepList) as the only extension point.- By pinning the template repository reference to a tag (
ref: refs/tags/plantillas-v1.2) and versioning the templates with semantic versioning. Each repository bumps its tag when it suits, so a breaking change is tried out in one before it spreads. The template lives incontoso-infra, with its branch policies and the mandatory review byContoso-Infraestructura.
Conclusion
Contoso's main branch is no longer merely protected: it is verified. You understand continuous integration as a discipline — integrate often because integrating late hurts non-linearly — with its four requirements and the cultural rule that holds it up: a broken build is priority number one. You know how to choose between hosted agents, clean and maintained by Microsoft, and self-hosted agents, essential when you have to reach resources that only exist behind a private endpoint inside vnet-contoso-pro; and you know the real numbers: 1,800 minutes and one parallel job free per private project, and about $40 a month for each additional parallel job.
You have written the contoso-reservas-ci azure-pipelines.yml understanding every piece: trigger with path exclusions, pr to hook into the build validation policy from 05-02, schedules for the nightly build that catches what breaks without you touching anything, and the stages → jobs → steps hierarchy with the difference between task and script. Inside it you have put the essentials — restore, build with -warnaserror, test while publishing results and coverage with failTaskOnFailedTests, analyze vulnerable dependencies and publish the artifact — you have sped it up with a cache whose key is derived from the manifest, and you have made it reusable with templates, telling template from extends: only extends turns the security analysis into something no team can skip. And you have kept module 4's rule intact: no secrets in the YAML, all of them in a variable group linked to kv-contoso-pro, mapped explicitly and authorized only to the pipelines that need them.
The result of all this is a file: the web-reservas artifact, built once, tested and waiting. But it is still waiting: nobody has taken it to app-contoso-reservas-pro, and the Friday night deployment carries on exactly as before. In the next lesson, Continuous Deployment with Environments and Approvals, that artifact will travel. You will create the desarrollo, preproduccion and produccion environments with their record of which version is in each one, you will add Marta Ríos' manual approval and the windows that prevent deploying on a Friday afternoon, you will compare the deployment strategies, and you will run Contoso's real blue-green deployment with the preproduccion slot swap you already know from module 2, with backward-compatible database migrations and a rollback that genuinely works.
Azure Course
Module 1: Introduction to Azure
- What Is Azure?
- Service Models, Regions and Availability Zones
- Creating and Setting Up Your Azure Account
- A Tour of the Azure Portal
- Azure Resource Manager: Subscriptions, Resource Groups and Tags
- Azure CLI, PowerShell and Cloud Shell
Module 2: Core Azure Services
- Azure Virtual Machines
- Compute Scaling and High Availability
- Azure App Service
- Azure Storage: Blobs, Files, Queues and Tables
- Azure Networking: Virtual Networks, Subnets and NSGs
- Hybrid Connectivity and Global Delivery
Module 3: Azure Databases
- Choosing the Right Data Service
- Azure SQL Database
- Azure Cosmos DB
- Azure Database for MySQL
- Azure Database for PostgreSQL
- Data Analytics: Data Lake, Data Factory and Synapse
Module 4: Security in Azure
- Microsoft Entra ID and Identity Management
- RBAC and Managed Identities
- Azure Key Vault
- DDoS Protection and Web Application Firewall
- Microsoft Defender for Cloud
- Governance and Compliance with Azure Policy
Module 5: Azure DevOps
- Introduction to Azure DevOps
- Azure Repos
- Azure Pipelines: Continuous Integration
- Continuous Deployment with Environments and Approvals
- Azure Artifacts
- Infrastructure as Code with Bicep
Module 6: Advanced Azure Services
- Containers in Azure: Container Registry and Container Apps
- Azure Kubernetes Service (AKS)
- Azure Functions
- Azure Logic Apps
- Messaging and Events: Service Bus, Event Grid and Event Hubs
- Azure AI Services
Module 7: Monitoring and Management
- Azure Monitor: Metrics, Alerts and Dashboards
- Log Analytics and KQL Queries
- Application Insights
- Azure Automation and Runbooks
- Backup and Disaster Recovery
Module 8: Cost Management and Optimization
- Pricing Calculator and Cost Estimation
- Azure Cost Management: Analysis, Budgets and Alerts
- Reservations, Savings Plans and Azure Hybrid Benefit
- Azure Advisor
- Optimization Strategies and FinOps Culture
