Azure DevOps Pipelines CI/CD
Build CI/CD pipelines with Azure DevOps YAML pipelines, covering stages, jobs, templates, environments, approvals, and deployment strategies.
Prerequisites
- Basic understanding of CI/CD concepts
- Familiarity with YAML syntax
- Experience with Git version control
- Azure DevOps organization account
Azure DevOps CI/CD Overview
Azure DevOps is a comprehensive suite of development tools that covers the entire software development lifecycle. At its core, Azure Pipelines provides a powerful CI/CD platform that supports building, testing, and deploying code to any target: Azure services, other cloud providers, on-premises servers, containers, or mobile devices. Pipelines can be triggered automatically by code changes, pull requests, schedules, or manual approvals, enabling everything from simple continuous integration to complex multi-stage deployment workflows.
Azure Pipelines supports two pipeline definition formats: YAML pipelines (defined in code alongside your application) and Classic pipelines (defined through the visual designer in the Azure DevOps portal). This guide focuses on YAML pipelines, which are the recommended approach because they provide version control, code review, branch policies, and reusability through templates, all the benefits of infrastructure-as-code applied to your CI/CD process.
Whether you are building a simple web application, a complex microservices system, or a multi-environment enterprise deployment, understanding YAML pipeline concepts is essential for efficient, reliable, and secure software delivery on Azure.
Azure DevOps vs GitHub Actions
Azure DevOps Pipelines and GitHub Actions are both first-party Microsoft CI/CD platforms. If your source code lives in Azure Repos, Azure Pipelines is the natural choice with deep integration. If your code is in GitHub, you can use either. Azure Pipelines has a native GitHub integration, and GitHub Actions provides a similar YAML-based pipeline experience. Azure Pipelines has advantages in complex enterprise scenarios with its richer approval workflows, environments, and deployment group features.
YAML Pipeline Fundamentals
A YAML pipeline is defined in an azure-pipelines.yml file at the root of your repository. The pipeline definition describes the triggers, stages, jobs, and steps that make up your CI/CD workflow. Understanding the hierarchy of these concepts is essential for building effective pipelines.
Pipeline Hierarchy
| Level | Description | Contains |
|---|---|---|
| Pipeline | Top-level container for the entire CI/CD workflow | Triggers, variables, stages |
| Stage | A logical boundary in the pipeline (e.g., Build, Test, Deploy) | One or more jobs |
| Job | A unit of work that runs on a single agent | One or more steps |
| Step | An individual operation (script, task, or template reference) | A single action |
# Trigger on pushes to main and feature branches
trigger:
branches:
include:
- main
- feature/*
paths:
exclude:
- docs/*
- '*.md'
# Trigger on pull requests targeting main
pr:
branches:
include:
- main
# Pipeline-level variables
variables:
buildConfiguration: 'Release'
dotnetVersion: '8.0.x'
# Use Ubuntu latest as the default agent pool
pool:
vmImage: 'ubuntu-latest'
stages:
- stage: Build
displayName: 'Build & Test'
jobs:
- job: BuildApp
displayName: 'Build Application'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- script: dotnet restore
displayName: 'Restore NuGet packages'
- script: dotnet build --configuration $(buildConfiguration) --no-restore
displayName: 'Build solution'
- script: dotnet test --configuration $(buildConfiguration) --no-build --logger trx --results-directory $(Agent.TempDirectory)/TestResults
displayName: 'Run unit tests'
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
searchFolder: '$(Agent.TempDirectory)/TestResults'
condition: always()
- script: dotnet publish --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)
displayName: 'Publish application'
- task: PublishBuildArtifacts@1
displayName: 'Upload build artifacts'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'drop'Predefined Variables
Azure Pipelines provides numerous predefined variables that give you information about the build context, repository, agent, and environment. Some of the most commonly used variables include:
$(Build.BuildId): The unique numeric ID of the build run$(Build.SourceBranch): The full branch name (e.g.,refs/heads/main)$(Build.SourceBranchName): The short branch name (e.g.,main)$(Build.Repository.Name): The repository name$(System.DefaultWorkingDirectory): The working directory for the pipeline$(Agent.TempDirectory): A temporary directory cleaned after each job$(Build.ArtifactStagingDirectory): Directory for staging artifacts before publishing
Multi-Stage Pipelines
Multi-stage pipelines define the complete deployment workflow from build through production deployment in a single YAML file. Each stage can target a different environment, use different approval gates, and depend on the success of previous stages. This gives you a visual representation of your entire release process directly in the Azure DevOps portal.
trigger:
branches:
include:
- main
variables:
azureSubscription: 'Production-ServiceConnection'
appName: 'mywebapp'
resourceGroup: 'rg-app'
stages:
# ---- BUILD STAGE ----
- stage: Build
displayName: 'Build & Package'
jobs:
- job: Build
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
displayName: 'Build Docker image'
inputs:
containerRegistry: 'acr-service-connection'
repository: '$(appName)'
command: 'buildAndPush'
Dockerfile: '**/Dockerfile'
tags: |
$(Build.BuildId)
latest
- task: PublishPipelineArtifact@1
displayName: 'Publish Kubernetes manifests'
inputs:
targetPath: 'k8s/'
artifact: 'manifests'
# ---- DEV STAGE ----
- stage: DeployDev
displayName: 'Deploy to Dev'
dependsOn: Build
condition: succeeded()
jobs:
- deployment: DeployToDev
displayName: 'Deploy to Development'
environment: 'dev'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- task: KubernetesManifest@0
displayName: 'Deploy to AKS (Dev)'
inputs:
action: 'deploy'
kubernetesServiceConnection: 'aks-dev-connection'
namespace: 'myapp-dev'
manifests: '$(Pipeline.Workspace)/manifests/*.yml'
containers: 'myacr.azurecr.io/$(appName):$(Build.BuildId)'
# ---- STAGING STAGE ----
- stage: DeployStaging
displayName: 'Deploy to Staging'
dependsOn: DeployDev
condition: succeeded()
jobs:
- deployment: DeployToStaging
displayName: 'Deploy to Staging'
environment: 'staging'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- task: KubernetesManifest@0
displayName: 'Deploy to AKS (Staging)'
inputs:
action: 'deploy'
kubernetesServiceConnection: 'aks-staging-connection'
namespace: 'myapp-staging'
manifests: '$(Pipeline.Workspace)/manifests/*.yml'
containers: 'myacr.azurecr.io/$(appName):$(Build.BuildId)'
- script: |
echo "Running integration tests against staging..."
npm run test:integration -- --base-url https://staging.myapp.com
displayName: 'Run integration tests'
# ---- PRODUCTION STAGE ----
- stage: DeployProd
displayName: 'Deploy to Production'
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployToProduction
displayName: 'Deploy to Production'
environment: 'production'
pool:
vmImage: 'ubuntu-latest'
strategy:
canary:
increments: [10, 50]
deploy:
steps:
- task: KubernetesManifest@0
displayName: 'Deploy canary to AKS (Prod)'
inputs:
action: 'deploy'
kubernetesServiceConnection: 'aks-prod-connection'
namespace: 'myapp-prod'
manifests: '$(Pipeline.Workspace)/manifests/*.yml'
containers: 'myacr.azurecr.io/$(appName):$(Build.BuildId)'
percentage: '$(strategy.increment)'
on:
success:
steps:
- script: echo "Canary deployment at $(strategy.increment)% succeeded"
failure:
steps:
- script: echo "Canary failed - rolling back"
- task: KubernetesManifest@0
displayName: 'Rollback deployment'
inputs:
action: 'reject'
kubernetesServiceConnection: 'aks-prod-connection'
namespace: 'myapp-prod'Stage Conditions
Pay close attention to the condition property on stages and jobs. Without explicit conditions, a stage will run whenever its dependencies succeed. For production stages, always add a branch condition like eq(variables['Build.SourceBranch'], 'refs/heads/main') to prevent accidental production deployments from feature branches. Combine conditions using and(), or(), and not() for complex logic.
Pipeline Templates & Reuse
Pipeline templates are the key to maintaining DRY (Don't Repeat Yourself) pipelines across multiple repositories and projects. Azure Pipelines supports four types of templates: stage templates, job templates, step templates, and variable templates. Templates can be stored in the same repository or in a separate template repository that is shared across your organization.
Template Types
| Template Type | Purpose | Example Use Case |
|---|---|---|
| Stage Template | Reusable stage definitions | Standard deployment stage with approval gates |
| Job Template | Reusable job definitions | Docker build job, Terraform plan/apply job |
| Step Template | Reusable step sequences | Security scanning steps, notification steps |
| Variable Template | Shared variable definitions | Environment-specific configuration, shared versions |
# Step template for building and testing .NET applications
parameters:
- name: solution
type: string
default: '**/*.sln'
- name: buildConfiguration
type: string
default: 'Release'
- name: dotnetVersion
type: string
default: '8.0.x'
- name: runTests
type: boolean
default: true
- name: publishArtifact
type: boolean
default: true
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK ${{ parameters.dotnetVersion }}'
inputs:
packageType: 'sdk'
version: ${{ parameters.dotnetVersion }}
- script: dotnet restore ${{ parameters.solution }}
displayName: 'Restore packages'
- script: dotnet build ${{ parameters.solution }} --configuration ${{ parameters.buildConfiguration }} --no-restore
displayName: 'Build solution'
- ${{ if eq(parameters.runTests, true) }}:
- script: |
dotnet test ${{ parameters.solution }} \
--configuration ${{ parameters.buildConfiguration }} \
--no-build \
--logger trx \
--collect:"XPlat Code Coverage" \
--results-directory $(Agent.TempDirectory)/TestResults
displayName: 'Run tests with code coverage'
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
searchFolder: '$(Agent.TempDirectory)/TestResults'
condition: always()
- task: PublishCodeCoverageResults@1
displayName: 'Publish code coverage'
inputs:
codeCoverageTool: 'Cobertura'
summaryFileLocation: '$(Agent.TempDirectory)/TestResults/**/coverage.cobertura.xml'
- ${{ if eq(parameters.publishArtifact, true) }}:
- script: dotnet publish ${{ parameters.solution }} --configuration ${{ parameters.buildConfiguration }} --no-build --output $(Build.ArtifactStagingDirectory)
displayName: 'Publish application'
- task: PublishPipelineArtifact@1
displayName: 'Upload artifact'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)'
artifact: 'drop'# Reference a template repository
resources:
repositories:
- repository: templates
type: git
name: DevOps/pipeline-templates
ref: refs/tags/v2.1.0 # Pin to a specific version
trigger:
branches:
include:
- main
stages:
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: 'ubuntu-latest'
steps:
# Use the step template from the shared repository
- template: templates/steps/dotnet-build.yml@templates
parameters:
solution: 'src/MyApp.sln'
buildConfiguration: 'Release'
dotnetVersion: '8.0.x'
runTests: true
# Add project-specific steps after the template
- template: templates/steps/security-scan.yml@templates
parameters:
scanPath: 'src/'
- stage: Deploy
dependsOn: Build
jobs:
# Use a job template for standard App Service deployment
- template: templates/jobs/deploy-app-service.yml@templates
parameters:
environment: 'production'
azureSubscription: 'Prod-ServiceConnection'
appServiceName: 'mywebapp-prod'Build & Test Automation
Effective CI pipelines catch issues early by running comprehensive build and test suites on every code change. Azure Pipelines provides built-in tasks for most popular languages and frameworks, along with the flexibility to run any command-line tool. Key best practices include running linters and static analysis, executing unit tests with code coverage, and publishing test results for visibility.
Test Result Publishing
Publishing test results to Azure DevOps provides rich visualizations of test outcomes, historical trends, and flaky test detection. Azure DevOps supports JUnit, NUnit, VSTest (TRX), and xUnit result formats.
Container-Based Builds
For complex build environments that require specific tools, libraries, or OS versions, you can run pipeline jobs inside Docker containers. This ensures build reproducibility and eliminates "works on my machine" issues.
jobs:
- job: BuildInContainer
displayName: 'Build in custom container'
pool:
vmImage: 'ubuntu-latest'
container:
image: 'myacr.azurecr.io/build-tools:latest'
endpoint: 'acr-service-connection'
steps:
- script: |
echo "Running inside container with pre-installed tools"
node --version
dotnet --version
terraform --version
displayName: 'Verify tool versions'
- script: |
npm ci
npm run lint
npm run build
npm run test:unit -- --coverage
displayName: 'Build and test frontend'
# Matrix strategy for multi-platform testing
- job: TestMatrix
displayName: 'Cross-platform tests'
strategy:
matrix:
Linux:
vmImage: 'ubuntu-latest'
os: 'linux'
Windows:
vmImage: 'windows-latest'
os: 'windows'
macOS:
vmImage: 'macOS-latest'
os: 'macos'
pool:
vmImage: $(vmImage)
steps:
- script: dotnet test --configuration Release --logger trx
displayName: 'Run tests on $(os)'
- task: PublishTestResults@2
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
condition: always()Release & Deployment Strategies
Azure Pipelines supports several deployment strategies that control how new code is rolled out to target environments. Choosing the right strategy depends on your risk tolerance, rollback requirements, and the nature of your application.
| Strategy | How It Works | Risk Level | Rollback Speed | Best For |
|---|---|---|---|---|
| RunOnce | Deploy once to all targets simultaneously | Highest | Requires redeployment | Dev/test environments, simple apps |
| Rolling | Deploy incrementally to subsets of targets | Medium | Stop and redeploy previous version | VM-based deployments, stateful services |
| Canary | Route a percentage of traffic to the new version | Low | Shift traffic back to old version | Web services, APIs, Kubernetes workloads |
| Blue-Green | Run two identical environments, swap traffic | Low | Instant (swap back) | App Service slots, load-balanced VMs |
stages:
- stage: DeployProd
displayName: 'Blue-Green Production Deploy'
jobs:
- deployment: BlueGreenDeploy
environment: 'production'
strategy:
runOnce:
deploy:
steps:
# Deploy to staging slot (green)
- task: AzureWebApp@1
displayName: 'Deploy to staging slot'
inputs:
azureSubscription: '$(azureSubscription)'
appType: 'webAppLinux'
appName: 'mywebapp-prod'
deployToSlotOrASE: true
resourceGroupName: '$(resourceGroup)'
slotName: 'staging'
package: '$(Pipeline.Workspace)/drop/**/*.zip'
# Warm up the staging slot
- task: AzureAppServiceManage@0
displayName: 'Start staging slot'
inputs:
azureSubscription: '$(azureSubscription)'
action: 'Start Azure App Service'
webAppName: 'mywebapp-prod'
specifySlotOrASE: true
resourceGroupName: '$(resourceGroup)'
slot: 'staging'
# Run smoke tests against staging slot
- script: |
echo "Running smoke tests against staging slot..."
curl -sf https://mywebapp-prod-staging.azurewebsites.net/health || exit 1
curl -sf https://mywebapp-prod-staging.azurewebsites.net/api/status || exit 1
echo "Smoke tests passed!"
displayName: 'Smoke test staging slot'
# Swap staging slot to production
- task: AzureAppServiceManage@0
displayName: 'Swap staging to production'
inputs:
azureSubscription: '$(azureSubscription)'
action: 'Swap Slots'
webAppName: 'mywebapp-prod'
resourceGroupName: '$(resourceGroup)'
sourceSlot: 'staging'
targetSlot: 'production'Environments & Approvals
Environments in Azure DevOps represent the deployment targets for your pipeline: development, staging, production, and any other target you deploy to. Environments provide a central place to configure approval gates, deployment history, and resource health checks. When a deployment job references an environment with approvals configured, the pipeline pauses and waits for the designated approvers to approve or reject the deployment.
Configuring Approvals & Checks
Approvals and checks are configured on the environment resource in Azure DevOps, not in the YAML file. This separation of concerns means that the pipeline definition describes what to deploy, while the environment configuration controls who can approve the deployment. Available check types include:
- Manual approval: One or more users must explicitly approve the deployment
- Branch control: Only allow deployments from specific branches
- Business hours: Only allow deployments during specified time windows
- Template validation: Ensure the pipeline extends from an approved template
- Invoke Azure Function: Run custom validation logic before proceeding
- Invoke REST API: Check an external system before allowing deployment
- Required template: Enforce that specific templates are used
Exclusive Lock Check
Use the Exclusive Lock check on production environments to prevent multiple pipeline runs from deploying simultaneously. This ensures sequential deployments and prevents conflicts. When a second pipeline run reaches a locked environment, it queues and waits for the first deployment to complete. This is especially important for stateful deployments or database migrations where concurrent changes could cause issues.
Service Connections & Security
Service connections are the mechanism by which Azure Pipelines authenticates to external services: Azure subscriptions, Docker registries, Kubernetes clusters, NuGet feeds, and any other service your pipeline needs to interact with. Properly securing service connections is critical because they represent privileged access to your production infrastructure.
Service Connection Types
| Connection Type | Authentication Method | Common Use |
|---|---|---|
| Azure Resource Manager | Service Principal or Managed Identity | Deploying to Azure services (App Service, AKS, etc.) |
| Docker Registry | Username/password or service principal | Pushing/pulling container images |
| Kubernetes | Kubeconfig or Azure subscription | Deploying to Kubernetes clusters |
| GitHub | GitHub App or PAT | Accessing GitHub repositories |
| NuGet/npm | API key or PAT | Publishing/consuming packages |
| SSH | SSH key pair | Deploying to Linux servers |
# Create a service principal for pipeline deployments
az ad sp create-for-rbac \
--name sp-pipeline-deploy-prod \
--role Contributor \
--scopes /subscriptions/<sub-id>/resourceGroups/rg-app-prod \
--sdk-auth
# The output JSON can be used to create an ARM service connection
# in Azure DevOps Project Settings > Service Connections
# For Workload Identity Federation (recommended - no secrets):
# 1. Create the service connection in Azure DevOps with
# "Workload Identity Federation" authentication
# 2. Azure DevOps automatically manages the federated credential
# 3. No client secrets to rotate
# Lock down service connection permissions
# In Azure DevOps: Project Settings > Service Connections
# - Set "Grant access permission to all pipelines" to OFF
# - Explicitly authorize specific pipelines
# - Add approval checks before use in production stagesWorkload Identity Federation
For Azure Resource Manager service connections, use Workload Identity Federation instead of client secrets. With federation, Azure DevOps exchanges a pipeline token for an Azure AD token without any stored secrets. This eliminates the risk of secret leakage and removes the operational burden of secret rotation. Workload Identity Federation is now the default for new ARM service connections in Azure DevOps.
Pipeline Optimization & Caching
Pipeline execution time directly impacts developer productivity and feedback loop speed. A pipeline that takes 30 minutes to run is fundamentally different from one that takes 5 minutes; developers will avoid running long pipelines, leading to larger batches, more merge conflicts, and slower defect detection. Optimizing pipeline performance is therefore a high-leverage investment.
Caching Dependencies
The Cache@2 task stores and restores directories between pipeline runs, dramatically reducing time spent downloading and installing dependencies. The cache key is computed from a hash of lockfiles, ensuring the cache is invalidated when dependencies change.
variables:
NUGET_PACKAGES: $(Pipeline.Workspace)/.nuget/packages
npm_config_cache: $(Pipeline.Workspace)/.npm
jobs:
- job: OptimizedBuild
pool:
vmImage: 'ubuntu-latest'
steps:
# Cache NuGet packages
- task: Cache@2
displayName: 'Cache NuGet packages'
inputs:
key: 'nuget | "$(Agent.OS)" | **/packages.lock.json'
restoreKeys: |
nuget | "$(Agent.OS)"
path: $(NUGET_PACKAGES)
# Cache npm packages
- task: Cache@2
displayName: 'Cache npm packages'
inputs:
key: 'npm | "$(Agent.OS)" | package-lock.json'
restoreKeys: |
npm | "$(Agent.OS)"
path: $(npm_config_cache)
# Parallel build steps using jobs
- script: dotnet restore --locked-mode
displayName: 'Restore (using cached packages)'
- script: dotnet build --no-restore -c Release
displayName: 'Build'
# Run tests in parallel using test slicing
- task: DotNetCoreCLI@2
displayName: 'Run tests (parallel)'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '--no-build -c Release --logger trx -m:4'
publishTestResults: trueAdditional Optimization Techniques
- Parallel jobs: Split independent work (front-end build, back-end build, integration tests) into separate jobs that run concurrently on different agents.
- Incremental builds: Use file change detection to skip stages that are not affected by the current code change (e.g., skip front-end build if only back-end files changed).
- Self-hosted agents: For builds that require large dependencies or proprietary tools, use self-hosted agents with pre-installed tools to eliminate installation time.
- Pipeline artifacts vs Build artifacts: Use
PublishPipelineArtifactinstead ofPublishBuildArtifactsfor faster artifact upload and download. - Shallow fetch: Set
fetchDepth: 1in the checkout step to avoid downloading the full git history when it is not needed.
Best Practices & Troubleshooting
Building reliable, maintainable CI/CD pipelines requires discipline and adherence to proven patterns. The following best practices address the most common challenges teams face when scaling their Azure DevOps pipeline infrastructure.
- Pin template versions: When referencing shared template repositories, always pin to a specific tag or commit SHA rather than a branch. This prevents unexpected pipeline changes when someone updates the template repository.
- Use variable groups for secrets: Store sensitive values (API keys, connection strings, certificates) in Azure DevOps variable groups linked to Azure Key Vault. Never hard-code secrets in YAML files.
- Enforce branch policies: Require successful pipeline runs before allowing merges to main. Configure required reviewers, linked work items, and comment resolution as additional branch policy checks.
- Implement pipeline decorators: Use pipeline decorators (an organization-level feature) to inject mandatory steps like security scanning, compliance checks, or notification steps into every pipeline without modifying individual YAML files.
- Monitor pipeline analytics: Use the built-in pipeline analytics to track pass rates, duration trends, and flaky tests. Set up alerts for pipelines that consistently fail or have increasing duration.
- Use YAML anchors for DRY configs: Azure Pipelines supports YAML anchors and aliases for reducing duplication within a single file (though templates are preferred for cross-file reuse).
- Separate CI and CD concerns: Keep build and test logic separate from deployment logic where possible. This makes it easier to trigger deployments independently and reuse build artifacts across multiple target environments.
Pipeline Troubleshooting
When a pipeline fails, start with the failing step's logs and work backward. Enable system.debug: true in pipeline variables for verbose logging. For agent-level issues, check the agent diagnostics logs. For intermittent failures, look at the pipeline analytics for flaky test patterns. The Azure DevOps REST API also provides detailed pipeline run data that can be analyzed programmatically for trend detection.
Key Takeaways
- 1YAML pipelines provide version-controlled, reviewable CI/CD definitions alongside application code.
- 2Multi-stage pipelines enable build, test, and deploy workflows in a single pipeline file.
- 3Pipeline templates enable reuse of common patterns across multiple pipelines and teams.
- 4Environments with approval gates enforce governance for production deployments.
- 5Service connections securely authenticate pipelines to Azure subscriptions and external services.
- 6Caching, parallelism, and pipeline optimization reduce build times significantly.
Frequently Asked Questions
What is the difference between Classic and YAML pipelines?
How many free pipeline minutes do I get?
What are pipeline templates?
How do environments and approvals work?
Can I use Azure Pipelines with GitHub repositories?
Written by CloudToolStack Team
Cloud engineers and architects with hands-on experience across AWS, Azure, and GCP. We write guides based on real-world production patterns, not just documentation rewrites.
Disclaimer: This guide is for educational purposes. Cloud services change frequently; always refer to official documentation for the latest information. AWS, Azure, and GCP are trademarks of their respective owners.