Build Azure CI/CD for a .NET API

Deploy a .NET API with Terraform and Azure Pipelines on App Service.

Introduction

30 Second Summary

Code that works on your computer can still fail when it reaches the people who need it. Manual releases make every change harder to repeat and easier to trust.

In this project, you will build a secure CI/CD release system for a .NET health API on Azure App Service. A multi-stage Azure Pipeline validates your Terraform infrastructure before deploying each change through workload identity federation.

What You'll Build

You will push a change to main, watch separate validation and deployment stages turn green, then open a public HTTPS health endpoint that returns healthy JSON.

By the end of this project, you'll have:

  • A live health API you can open over HTTPS to see service JSON from / plus a healthy response from /health.
  • A repeatable release flow you can show in Azure Pipelines, complete with Terraform validation, a successful Docker build, and separate deployment evidence.
  • Operational evidence you can inspect through Azure Monitor, including platform metrics, the application startup message, and deployment activity.
  • Secret Mission: Protect main with required build validation, deliberately break Terraform in a pull request, then repair it until the policy turns green.

Are there any prerequisites?

You need a Linux computer with internet access plus a Microsoft account that can create an active Azure subscription. Docker runs on the Microsoft-hosted pipeline agent, so you do not need to install it locally.

Before We Start

This first checkpoint locks in your goal before any hands-on work begins. You are shipping the Portfolio API to demonstrate job-ready Azure DevOps fundamentals through CI/CD, infrastructure as code, and deployment.

Set Up Your Free Azure DevOps Lab

A repeatable release depends on working access plus a verified toolchain. Account permissions or missing command-line tools can otherwise stop the first deployment before your API reaches Azure.

In this step, you create a linked Microsoft Azure lab inside Azure DevOps Services. You also prepare Git, .NET, Terraform, Visual Studio Code, Azure CLI, plus Bash for the work ahead.

In this step, get ready to:
  • Activate an Azure subscription.
  • Create a linked private Azure DevOps project.
  • Install the pinned Linux toolchain.
Create your Azure subscription and project

The Azure subscription provides the billing boundary for the cloud resources you create later. The project keeps its App Service host on the Free F1 tier.

Expect identity verification

Creating an Azure account can feel risky because the signup establishes billing access. Azure may request a phone number plus a payment card for identity verification.

A temporary authorization may appear during verification. The project remains on the free path while you follow the specified resource settings.

  • Open the official Azure account page in your browser.
  • Choose the Azure free account option if your Microsoft account is eligible.
  • Sign in with the Microsoft account reserved for this project.
  • Complete the requested identity verification.
  • Finish the subscription activation flow.

You should see an active subscription associated with your account. That subscription becomes the cloud boundary for your portfolio lab.

Subscription not active?

Confirm that you used the eligible Microsoft account. Check that your country or region matches your billing address.

Still blocked? Help me troubleshoot Azure subscription activation

An Azure DevOps organization groups related projects. Linking the organization to your subscription enables the free Microsoft-hosted pipeline grant for private projects.

  • Open the official Azure DevOps organization guide in your browser.
  • Sign in with the Microsoft account that owns your subscription.
  • Select New organization.
  • Enter a unique organization name: your unique Azure DevOps organization name.
  • Select the closest hosting geography.
  • Select your active Azure subscription for billing.
  • Select Continue.

Azure DevOps creates the organization under your account. The linked subscription makes the private-project pipeline allowance available.

The organization now needs a private project to hold your source code plus the delivery pipeline.

  • Select the Azure DevOps logo to return to the Projects page.
  • Select New project.
  • Enter AzureDevOpsPortfolio as the project name.
  • Set the project visibility to Private.
  • Keep Git as the source control type.
  • Select Create.

Azure DevOps opens the welcome page for AzureDevOpsPortfolio. The project is isolated from public access.

  • Select Repos in the project navigation.

The Azure Repos page should load inside AzureDevOpsPortfolio. This confirms that private source control is available.

  • Select Pipelines in the project navigation.

The Azure Pipelines page should load inside the same project. You now have the services required for the future delivery workflow.

Install the local development tools

Your local tools create the source code plus run the first checks. Version checks keep your Linux environment aligned with the future pipeline.

Why is Docker missing?

The future pipeline builds the Docker image on a Microsoft-hosted Ubuntu agent. Your Linux computer does not need a local Docker installation for this project.

Git records the project history that Azure Repos receives later. Start by checking whether its command is available.

  • Check your Git installation by running this command in your Bash terminal:
git --version

What does this command do?

The command asks Git to print its installed version. Any version output confirms that Bash can find the executable.

✔️ I see a Git version

Git is ready. Your Linux computer can now create local repository history.

ⓧ Command not found

Install Git with the package manager for your Linux distribution.

Debian or Ubuntu

  • Install Git by running this command:
sudo apt install git-all
What does this command do?

The package manager installs Git plus its supporting tools from the configured Debian or Ubuntu repositories.

Fedora or RHEL

  • Install Git by running this command:
sudo dnf install git-all
What does this command do?

The package manager installs Git plus its supporting tools from the configured Fedora or RHEL repositories.

  • Repeat the Git version check after installation.

Git still unavailable?

Start a fresh Bash session after installation. This reloads the executable path.

Still stuck? Help me diagnose my Git installation

Visual Studio Code gives you one place to edit the API plus its infrastructure files. Check whether the desktop application is already available.

  • Open your Linux application launcher.
  • Search for Visual Studio Code.
  • Select Visual Studio Code if it appears in the results.

✔️ Visual Studio Code opens

Visual Studio Code is ready. Leave it open for the final checks.

ⓧ Visual Studio Code is not installed

Microsoft publishes Debian packages plus RPM packages for Linux. It also publishes a Snap package that provides one installation command.

  • Install Visual Studio Code through Snap by running this command:
sudo snap install --classic code

What does this command do?

Snap installs the stable Visual Studio Code package. The classic option gives the editor the file access required for development work.

  • Return to your Linux application launcher after installation.
  • Search for Visual Studio Code again.
  • Select Visual Studio Code from the results.

Visual Studio Code will not install?

Use the official Visual Studio Code Linux guide to choose the Debian or RPM package for your distribution.

Need a hand? Help me troubleshoot Visual Studio Code on Linux

The API uses .NET SDK 10.0.402. Checking the exact SDK now prevents local builds from drifting away from the pipeline.

  • Inspect the installed .NET environment by running this command:
dotnet --info

What does this command do?

The command prints the installed SDKs plus the runtime environment. Look for SDK version 10.0.402 near the top of the output.

✔️ I see version 10.0.402

The required .NET SDK is ready. Your local build can match the future pipeline.

ⓧ I see an older version

Install SDK 10.0.402 beside your existing SDKs with Microsoft's user-local installation script.

  • Install the pinned .NET SDK by running this command:
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 10.0.402

What does this command do?

The command downloads Microsoft's stable Bash installer. The installer places SDK 10.0.402 in $HOME/.dotnet.

  • Add the .NET locations to your Bash profile by running these commands:
echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.bashrc
echo 'export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools' >> ~/.bashrc

What do these commands do?

The first line records the user-local .NET location. The second line makes the SDK plus global tools available to future Bash sessions.

  • Close your current Bash terminal.
  • Start a new Bash terminal.
  • Repeat the .NET environment check.

You should now see SDK 10.0.402 in the output.

ⓧ Command not found

Install the SDK in your home directory. This installation does not require administrator access to the SDK files.

  • Install the pinned .NET SDK by running this command:
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 10.0.402

What does this command do?

The command downloads the stable installer. The script installs SDK 10.0.402 in the default user-local .NET directory.

  • Add the .NET locations to your Bash profile by running these commands:
echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.bashrc
echo 'export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools' >> ~/.bashrc

What do these commands do?

These profile entries let new Bash sessions find the .NET executable. They also expose the user-local tools directory.

  • Close your current Bash terminal.
  • Start a new Bash terminal.
  • Repeat the .NET environment check.

You should now see SDK 10.0.402 in the output.

Still seeing the wrong .NET SDK?

Confirm that your Bash profile contains the .NET root plus tools path entries. Start a fresh terminal after changing the profile.

Need help? Help me fix my .NET SDK path

Install the cloud tools and verify access

Azure CLI authenticates your local work against the subscription. This project requires version 2.91.0 or later.

  • Inspect the installed Azure CLI version by running this command:
az version

What does this command do?

The command prints the Azure CLI component versions. Confirm that the main Azure CLI entry reports 2.91.0 or later.

✔️ I see version 2.91.0 or higher

Azure CLI is ready for account authentication plus subscription discovery.

ⓧ I see an older version

Configure Microsoft's current package repository through the official Azure CLI Linux guide. Reinstalling from that source updates the command to the supported release.

Debian or Ubuntu

  • Update Azure CLI from Microsoft's configured repository by running this command:
sudo apt-get install azure-cli
What does this command do?

The package manager installs the Azure CLI package from the Microsoft repository configured for Debian or Ubuntu.

Fedora or RHEL

  • Update Azure CLI from Microsoft's configured repository by running this command:
sudo dnf install azure-cli
What does this command do?

The package manager installs the Azure CLI package from the Microsoft repository configured for Fedora or RHEL.

  • Repeat the Azure CLI version check after installation.

ⓧ Command not found

Configure Microsoft's package repository through the official Azure CLI Linux guide. Finish with the installation command for your Linux distribution.

Debian or Ubuntu

  • Install Azure CLI from Microsoft's configured repository by running this command:
sudo apt-get install azure-cli
What does this command do?

The command installs Azure CLI from the Microsoft repository configured for Debian or Ubuntu.

Fedora or RHEL

  • Install Azure CLI from Microsoft's configured repository by running this command:
sudo dnf install azure-cli
What does this command do?

The command installs Azure CLI from the Microsoft repository configured for Fedora or RHEL.

  • Repeat the Azure CLI version check after installation.

Azure CLI version still too old?

Confirm that your package manager uses Microsoft's current repository. A distribution repository can contain an older package.

Still stuck? Help me update Azure CLI on Linux

Terraform converts infrastructure settings into repeatable configuration. The project pins Terraform CLI to 1.17.0 so local validation matches the future pipeline.

  • Inspect the installed Terraform version by running this command:
terraform version

What does this command do?

The command prints the Terraform binary version plus its platform. Confirm that the first line reports 1.17.0.

✔️ I see version 1.17.0

Terraform matches the project pin. Local infrastructure checks can now reproduce the pipeline environment.

ⓧ I see an older version

Configure HashiCorp's current package repository through the official Terraform installation guide. Reinstall Terraform from that repository to reach the project pin.

Debian or Ubuntu

  • Update Terraform from HashiCorp's configured repository by running this command:
sudo apt update && sudo apt install terraform
What does this command do?

The first command refreshes the package index. The second command installs Terraform from the configured HashiCorp repository.

Fedora or RHEL

  • Update Terraform from HashiCorp's configured repository by running this command:
sudo dnf -y install terraform
What does this command do?

The command installs Terraform from the configured HashiCorp repository for Fedora or RHEL.

  • Repeat the Terraform version check after installation.

ⓧ Command not found

Configure HashiCorp's package repository through the official Terraform installation guide. Finish with the installation command for your Linux distribution.

Debian or Ubuntu

  • Install Terraform from HashiCorp's configured repository by running this command:
sudo apt update && sudo apt install terraform
What does this command do?

The package manager refreshes its index. It then installs Terraform from the configured HashiCorp repository.

Fedora or RHEL

  • Install Terraform from HashiCorp's configured repository by running this command:
sudo dnf -y install terraform
What does this command do?

The command installs Terraform from the configured HashiCorp repository for Fedora or RHEL.

  • Repeat the Terraform version check after installation.

Terraform version does not match?

Start a fresh Bash terminal after installation. Confirm that another Terraform binary earlier in your path is not shadowing the package-manager installation.

Need help? Help me fix my Terraform version

The toolchain is installed. Azure CLI now needs to prove that your Microsoft account can reach the active subscription.

The sign-in flow may open your browser. Use the same Microsoft account that owns the Azure subscription.

  • Start the Azure CLI sign-in flow by running this command:
az login

What does this command do?

The command starts interactive Azure authentication. Complete the browser flow with the Microsoft account that owns your lab subscription.

Before you list the account, do you expect Azure CLI to return the same subscription you activated in the browser?

  • List the subscriptions visible to your signed-in account by running this command:
az account list

What should you see?

The output lists subscriptions available to your account. Your active subscription should appear with its name plus its subscription ID.

Your Azure CLI session can now authenticate local Terraform work. The subscription output proves that your account can reach the required cloud boundary.

Subscription missing from the list?

Confirm that the browser sign-in used the same Microsoft account as Azure. Check the active directory if your account belongs to more than one directory.

Still missing? Help me find my Azure subscription

The final check brings every command together inside Visual Studio Code. Terraform can safely initialize an empty folder without creating cloud resources.

  • Switch back to Visual Studio Code.
  • Select Terminal in the top menu.
  • Select New Terminal.

You should see a Bash prompt inside Visual Studio Code. This terminal becomes the shared workspace for the final audit.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The command moves the integrated Bash terminal to your Desktop. The temporary verification folder stays easy to find.

  • Create the empty verification folder by running these commands:
mkdir verify-terraform
cd verify-terraform

What do these commands do?

The first command creates verify-terraform on your Desktop. The second command moves the integrated terminal into that empty folder.

Before you run the audit, which command do you expect to produce the most detailed environment output?

  • Verify the toolchain plus Azure access by running these commands:
dotnet --info
az version
git --version
az account list
terraform init

What should you confirm?

  • The .NET output includes SDK 10.0.402.
  • The Azure CLI output reports 2.91.0 or later.
  • Git prints an installed version.
  • The Azure account output includes your active subscription.
  • Terraform confirms that the empty directory is initialized.

That is the lab foundation complete. Your Azure account is reachable from a verified Linux toolchain inside Visual Studio Code.

One final check failed?

Match the failed command to its installation section above. Start a fresh integrated terminal after changing a package or Bash profile.

Need targeted help? Help me diagnose my lab toolchain

Your Azure DevOps lab is ready for real source code. Next, you build the first runnable version of the Portfolio API on your Linux computer.

Run the Portfolio API Locally

Your Azure lab is ready. Your verified Linux toolchain can now run the first version of the portfolio API.

Before cloud infrastructure enters the picture, you need visible proof that the application works on your computer. This local run defines the health contract that your release system must preserve.

In this step, get ready to:
  • Create the local .NET project structure.
  • Define JSON routes with startup logging.
  • Run the API locally to verify its health contract.
Create the API project files

A .NET project uses a small set of files to pin its SDK, configure its build, define its behavior, and exclude generated files. You will keep this structure inside one Visual Studio Code workspace.

  • Switch back to Visual Studio Code.
  • Click File in the menu bar.
  • Select Open Folder....
  • Choose your Desktop in the folder dialog.
  • Select New Folder.
  • Enter azure-devops-portfolio as the folder name.
  • Select the azure-devops-portfolio folder.
  • Select Select Folder.

What is a workspace?

A workspace gives the editor, terminal, and file tools the same project context. The integrated terminal starts from the folder or subfolder you select.

  • Select Yes, I trust the authors if the Workspace Trust dialog appears.
  • Confirm the Explorer view shows azure-devops-portfolio as the open folder.

Your project now has a dedicated workspace on the Desktop.

  • Select azure-devops-portfolio in the Explorer view.
  • Select New Folder.
  • Enter src as the folder name.
  • Select the new src folder.
  • Select New Folder.
  • Enter PortfolioApi as the folder name.

The Explorer now shows src/PortfolioApi inside azure-devops-portfolio.

  • Select azure-devops-portfolio in the Explorer.
  • Select New File....
  • Enter global.json as the file name.
  • Pin the project SDK by pasting this code into global.json:
{
  "sdk": {
    "version": "10.0.402"
  }
}

What does this file do?

  • The global.json file tells the dotnet command which SDK to use.
  • The 10.0.402 pin keeps local builds aligned with the later pipeline build.
  • Press Ctrl+S to save global.json.
  • Confirm global.json appears directly under azure-devops-portfolio in the Explorer.

Is the SDK file in the wrong place?

Check that global.json sits beside the src folder. Move it to azure-devops-portfolio if it appears inside src.

Still stuck? Help me place global.json at the top of my .NET project workspace.

  • Select azure-devops-portfolio in the Explorer.
  • Select New File....
  • Enter .gitignore as the file name.
  • Define the files that source control should ignore by pasting this code into .gitignore:
**/bin/
**/obj/
.vscode/
infra/.terraform/
infra/.terraform.lock.hcl
infra/terraform.tfvars
infra/*.tfstate
infra/*.tfstate.*
infra/tfplan

What does this file protect?

  • The bin and obj rules exclude generated .NET build output.
  • The .vscode rule excludes local editor settings.
  • The infra rules keep local Terraform state outside Git history.
  • Press Ctrl+S to save .gitignore.
  • Confirm the Explorer lists .gitignore beside global.json.

Did the file lose its dot?

Check that the file name begins with a dot. Rename it to .gitignore if the Explorer shows a different name.

Need a hand? Help me check my .gitignore file name and location.

  • Select src/PortfolioApi in the Explorer.
  • Select New File....
  • Enter PortfolioApi.csproj as the file name.
  • Configure the web project by pasting this code into PortfolioApi.csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

What does this project configuration do?

  • The web SDK gives the project its ASP.NET Core build behavior.
  • The net10.0 target selects .NET 10.
  • Nullable checks help catch missing-value mistakes during compilation.
  • Implicit usings keep common namespace imports out of Program.cs.
  • Press Ctrl+S to save PortfolioApi.csproj.
  • Confirm the Explorer lists PortfolioApi.csproj inside src/PortfolioApi.

Is the project file outside PortfolioApi?

Move PortfolioApi.csproj into src/PortfolioApi if it appears elsewhere. The run command depends on this location.

Need help? Help me fix my .NET project file structure.

  • Select src/PortfolioApi in the Explorer.
  • Select New File....
  • Enter Program.cs as the file name.
  • Create the application with its startup log and root route by pasting this code into Program.cs:
var app = WebApplication.Create(args);

app.Logger.LogInformation("Portfolio API started");

app.MapGet("/", () => new
{
    service = "portfolio-api",
    status = "running"
});

What does this code do?

  • The app variable holds the web application created from the command-line arguments.
  • The startup log records when the application reaches its configuration code.
  • The root route returns the service name with its running status.
  • Press Ctrl+S to save Program.cs.
  • Confirm the editor shows the MapGet("/") route below the startup log.

Does the root route look different?

Compare every quote and brace with the code block. A missing closing brace prevents the project from compiling.

Need help checking it? Help me compare my root route with the required Program.cs code.

  • Place your cursor on the blank line below the root route's closing });.
  • Add the health route and run loop by pasting this code:
app.MapGet("/health", () => new
{
    status = "healthy"
});

app.Run();

What completes the API?

  • The /health route exposes a small response that automated checks can request.
  • The app.Run() call starts the server loop.
  • Press Ctrl+S to save the completed Program.cs file.
  • Confirm the final line in the editor is app.Run();.

Is the health route nested inside the root route?

Check that the root route closes before app.MapGet("/health") begins. Both route mappings belong at the top level of Program.cs.

Still unsure? Help me check the structure of both routes in Program.cs.

✔️ Awesome, I've got everything!

All four project files now match the local API structure.

ⓧ I'd like to double check the full code

**/bin/
**/obj/
.vscode/
infra/.terraform/
infra/.terraform.lock.hcl
infra/terraform.tfvars
infra/*.tfstate
infra/*.tfstate.*
infra/tfplan
{
  "sdk": {
    "version": "10.0.402"
  }
}
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
var app = WebApplication.Create(args);

app.Logger.LogInformation("Portfolio API started");

app.MapGet("/", () => new
{
    service = "portfolio-api",
    status = "running"
});

app.MapGet("/health", () => new
{
    status = "healthy"
});

app.Run();
Run the API and check both routes

A Minimal API maps HTTP routes to JSON responses. Running it locally proves that the project compiles before any cloud resources depend on it.

The server remains active in the terminal while you test it. You will stop the process after checking both routes.

  • Right-click PortfolioApi in the Explorer.
  • Select Open in Integrated Terminal.
  • Confirm the terminal prompt is inside src/PortfolioApi.

Before you run the API, which route do you expect to provide the smallest health response?

  • Start the API on local port 5050 by running this command:
dotnet run --urls="http://localhost:5050"

What does this command do?

  • The dotnet run command builds the project before starting it.
  • The --urls option binds the server to http://localhost:5050.

You will see Portfolio API started in the startup output. The terminal remains active while the API serves local requests.

Did the API fail to start?

  • Confirm the terminal prompt is inside src/PortfolioApi.
  • Compare PortfolioApi.csproj with the full-code tab if the build reports a project configuration problem.
  • Stop any other process already using port 5050 before retrying.

Need targeted help? Help me troubleshoot why my local PortfolioApi does not start.

The root route identifies the service that is running.

You will see {"service":"portfolio-api","status":"running"} in the browser.

Before you check the health route, what status do you expect the API to report?

You will see {"status":"healthy"} in the browser. This response proves that the local health contract works.

That is your first visible win. The portfolio API now responds through both local routes.

Stop the API and inspect the startup log

A startup log gives an operator evidence that application code reached a known point. The same message can later appear through Azure App Service logging.

Before you stop the process, which startup message do you expect to remain in the terminal history?

  • Switch back to Visual Studio Code.
  • Select the active integrated terminal.
  • Press Ctrl+C to stop the local API.
  • Scroll through the terminal output.
  • Find the Portfolio API started log message.

The command prompt returns after the server stops. The startup message remains visible in the terminal history.

Why keep a startup log?

The message confirms that app.Logger.LogInformation() executed during startup. This gives you a recognizable application event to find in hosted logs later.

Your API now has a verified local baseline. Next, you will give it a secured Azure home with Terraform.

Provision a Secure Free App Service

Your local health endpoint proves the .NET API is ready to ship. The next goal is a public host with repeatable security settings.

You will use Terraform to describe an Azure App Service host as Infrastructure as Code. Provisioning the host before delivering the API exposes the boundary between infrastructure and application deployment.

In this step, get ready to:
  • Define the Terraform configuration and local variable values.
  • Provision a secured Free F1 Linux web app in Azure.
  • Inspect the security settings and test the public health route.
Define the Terraform configuration

Terraform reads HCL files that declare inputs, providers, resources, and outputs. Keeping these declarations in an infra folder separates the Azure infrastructure from the API source.

  • Return to your local project in Visual Studio Code.
  • Create an infra folder from the Explorer sidebar.
  • Create variables.tf inside the infra folder.
  • Define the reusable Terraform inputs by pasting this configuration into variables.tf:
variable "subscription_id" {
  description = "Azure subscription ID used by the AzureRM provider."
  type        = string
}

variable "app_name" {
  description = "Globally unique Azure App Service name."
  type        = string
}

variable "location" {
  description = "Azure region for the portfolio resources."
  type        = string
  default     = "West Europe"
}

What do these variables control?

  • The subscription_id variable directs the AzureRM provider to the Azure subscription that owns your resources.
  • The app_name variable holds the globally unique name used for the public App Service host.
  • The location variable keeps every resource in West Europe unless you deliberately override it.
  • Save variables.tf.
  • Confirm the Explorer sidebar lists variables.tf inside infra.

Can't find variables.tf?

Check that variables.tf sits inside infra. A file created beside infra will not belong to this Terraform configuration.

Still stuck? Help me place variables.tf in the correct Terraform folder

✔️ Awesome, I've got everything!

Your input variables are saved in infra/variables.tf.

ⓧ I'd like to double check the full code

variable "subscription_id" {
  description = "Azure subscription ID used by the AzureRM provider."
  type        = string
}

variable "app_name" {
  description = "Globally unique Azure App Service name."
  type        = string
}

variable "location" {
  description = "Azure region for the portfolio resources."
  type        = string
  default     = "West Europe"
}

This reference matches the complete variables.tf file for this project.

The AzureRM provider translates Terraform resources into Azure API operations. Pinning both Terraform and AzureRM keeps local runs aligned with the later pipeline.

  • Create main.tf inside the infra folder.
  • Add the required Terraform and AzureRM versions by pasting this block into main.tf:
terraform {
  required_version = "= 1.17.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "= 5.11.0"
    }
  }
}

Why pin these versions?

The configuration requires Terraform 1.17.0 with AzureRM 5.11.0. Exact pins make the same provider behavior available locally and in Azure Pipelines.

  • Save main.tf.
  • Confirm the file begins with a terraform block that pins both versions.

Seeing version highlights?

Compare the version strings with 1.17.0 and 5.11.0. Keep the equals sign inside each quoted constraint.

Still stuck? Help me check the version constraints in main.tf

  • Add the AzureRM provider configuration below the terraform block in main.tf:
provider "azurerm" {
  features {}

  subscription_id = var.subscription_id
}

What does the provider block do?

The provider reads var.subscription_id from your local variable file. Your existing Azure CLI sign-in supplies the local authentication.

  • Save main.tf.
  • Start an integrated terminal in the infra folder from the Explorer sidebar.
  • Initialize the pinned provider by running this command:
terraform init

What does terraform init do?

The command prepares the infra folder and downloads the pinned AzureRM provider. It also creates local initialization data that your existing .gitignore excludes from source control.

Terraform finishes initialization and returns control to the terminal. The provider is now available for validation and planning.

Terraform initialization failed?

  • Confirm the terminal is running inside the infra folder.
  • Check that your internet connection allows Terraform to download the AzureRM provider.
  • Compare the braces in the first two blocks of main.tf with the snippets above.

Still stuck? Help me troubleshoot terraform init in my infra folder

The provider needs the exact subscription selected for this lab. Your active Azure CLI session can list the available subscription records.

  • List the Azure subscriptions available to your signed-in account by running this command:
az account list

What should you read from this output?

Each subscription record includes an id field. Use the ID for the subscription that should contain this portfolio lab.

  • Record the selected subscription ID here: your Azure subscription ID.
  • Choose a globally unique App Service name containing only letters, numbers, and hyphens.
  • Record the chosen app name here: your globally unique app name.
  • Create terraform.tfvars.example inside the infra folder.
  • Add the safe example values by pasting this configuration into terraform.tfvars.example:
subscription_id = "00000000-0000-0000-0000-000000000000"
app_name        = "replace-with-a-globally-unique-app-name"

Why keep an example file?

The example documents the two required values without storing your own subscription details. Your local terraform.tfvars is already excluded by .gitignore.

  • Save terraform.tfvars.example.
  • Confirm the Explorer sidebar lists terraform.tfvars.example inside infra.

Example values look different?

Keep the example placeholders unchanged. Your personal values belong only in the ignored terraform.tfvars file.

Still stuck? Help me separate terraform.tfvars from its example file

  • Create terraform.tfvars inside the infra folder.
  • Copy the two assignment lines from terraform.tfvars.example into terraform.tfvars.
  • Replace 00000000-0000-0000-0000-000000000000 with your Azure subscription ID.
  • Replace replace-with-a-globally-unique-app-name with your globally unique app name.
  • Save terraform.tfvars.
  • Format and validate the current configuration by running these commands:
terraform fmt
terraform validate

What do these checks prove?

The first command applies Terraform's standard formatting to the configuration. The second command checks that the provider and variable references form a valid configuration.

Terraform reports that the configuration is valid. This proves the input and provider layer is ready for resources.

Validation did not pass?

  • Check every opening brace in variables.tf and main.tf has a closing brace.
  • Confirm the variable names are exactly subscription_id, app_name, and location.
  • Confirm both files are inside the same infra folder.

Still stuck? Help me troubleshoot terraform validate

✔️ Awesome, I've got everything!

Your reusable example values are saved in infra/terraform.tfvars.example.

ⓧ I'd like to double check the full code

subscription_id = "00000000-0000-0000-0000-000000000000"
app_name        = "replace-with-a-globally-unique-app-name"

This reference matches the complete terraform.tfvars.example file for this project.

Provision the App Service resources

The configuration now has a provider and project-specific values. The next declarations create the resource group, Free F1 compute plan, and secured Linux web app.

  • Append the resource group declaration below the provider block in main.tf:
resource "azurerm_resource_group" "portfolio" {
  name     = "rg-azure-devops-portfolio"
  location = var.location

  tags = {
    project = "azure-devops-portfolio"
  }
}

What does this resource create?

The resource group named rg-azure-devops-portfolio becomes the container for the hosting resources. Its location comes from var.location.

  • Save main.tf.
  • Validate the new resource declaration by running this command:
terraform validate

What did validation check?

Terraform checked the resource group against the installed AzureRM provider schema. A valid result confirms its required name and location are present.

The configuration remains valid with the resource group included.

Resource group validation failed?

Compare the resource type, resource name, and var.location reference with the block above. A misspelled identifier breaks later references.

Still stuck? Help me fix the azurerm_resource_group block

An App Service plan supplies the compute tier used by the web app. The F1 Linux tier keeps this lab inside the free hosting design.

  • Append the service plan declaration below the resource group in main.tf:
resource "azurerm_service_plan" "portfolio" {
  name                = "asp-azure-devops-portfolio"
  resource_group_name = azurerm_resource_group.portfolio.name
  location            = azurerm_resource_group.portfolio.location
  os_type             = "Linux"
  sku_name            = "F1"

  tags = {
    project = "azure-devops-portfolio"
  }
}

How is the plan connected?

The plan reads its resource group and location from azurerm_resource_group.portfolio. The Linux operating system and F1 SKU define the free host.

  • Save main.tf.
  • Validate the service plan declaration by running this command:
terraform validate

What did Terraform confirm?

Terraform confirmed that the plan references an existing resource declaration and uses supported Linux plan settings. The dependency is now part of Terraform's resource graph.

The configuration remains valid with the Free F1 service plan included.

Service plan validation failed?

  • Confirm the plan references azurerm_resource_group.portfolio in both dependency lines.
  • Check that os_type is Linux.
  • Check that sku_name is F1.

Still stuck? Help me fix the azurerm_service_plan block

The web app declaration applies the runtime and transport controls to the public host. It also enables file-system application logs so the API startup message can be inspected after deployment.

  • Append the Linux web app declaration below the service plan in main.tf:
resource "azurerm_linux_web_app" "portfolio" {
  name                = var.app_name
  resource_group_name = azurerm_resource_group.portfolio.name
  location            = azurerm_service_plan.portfolio.location
  service_plan_id     = azurerm_service_plan.portfolio.id
  https_only          = true

  site_config {
    always_on              = false
    ftps_state             = "Disabled"
    minimum_tls_version     = "1.2"
    scm_minimum_tls_version = "1.2"

    application_stack {
      dotnet_version = "10.0"
    }
  }

  logs {
    application_logs {
      file_system_level = "Information"
    }
  }

  tags = {
    project = "azure-devops-portfolio"
  }
}

What security does this add?

  • The https_only setting requires HTTPS for public requests.
  • The two minimum version settings require TLS 1.2 for the app and deployment endpoint.
  • The ftps_state setting disables FTPS access.
  • The application stack selects .NET 10.0.
  • The logging block captures application output at the Information level.
  • Create outputs.tf inside the infra folder.
  • Expose the web app's generated HTTPS address by pasting this configuration into outputs.tf:
output "app_url" {
  description = "HTTPS URL of the deployed Azure App Service."
  value       = "https://${azurerm_linux_web_app.portfolio.default_hostname}"
}

Why create an output?

The app_url output turns Azure's generated hostname into a reusable HTTPS URL. Terraform prints it after a successful apply.

  • Save main.tf.
  • Save outputs.tf.
  • Format and validate the complete configuration by running these commands:
terraform fmt
terraform validate

What does this check cover?

Terraform now validates all three Azure resources and the hostname output together. It also checks references between the resource group, service plan, web app, and output.

Terraform reports a valid configuration. Your infrastructure definition is ready for a plan.

Complete configuration still invalid?

  • Check the closing braces around site_config, logs, and tags.
  • Confirm outputs.tf references azurerm_linux_web_app.portfolio.default_hostname.
  • Confirm all Terraform files are inside the same infra folder.

Still stuck? Help me troubleshoot the complete Terraform configuration

✔️ Awesome, I've got everything!

Your complete Azure resource configuration is saved in infra/main.tf.

ⓧ I'd like to double check the full code

terraform {
  required_version = "= 1.17.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "= 5.11.0"
    }
  }
}

provider "azurerm" {
  features {}

  subscription_id = var.subscription_id
}

resource "azurerm_resource_group" "portfolio" {
  name     = "rg-azure-devops-portfolio"
  location = var.location

  tags = {
    project = "azure-devops-portfolio"
  }
}

resource "azurerm_service_plan" "portfolio" {
  name                = "asp-azure-devops-portfolio"
  resource_group_name = azurerm_resource_group.portfolio.name
  location            = azurerm_resource_group.portfolio.location
  os_type             = "Linux"
  sku_name            = "F1"

  tags = {
    project = "azure-devops-portfolio"
  }
}

resource "azurerm_linux_web_app" "portfolio" {
  name                = var.app_name
  resource_group_name = azurerm_resource_group.portfolio.name
  location            = azurerm_service_plan.portfolio.location
  service_plan_id     = azurerm_service_plan.portfolio.id
  https_only          = true

  site_config {
    always_on              = false
    ftps_state             = "Disabled"
    minimum_tls_version     = "1.2"
    scm_minimum_tls_version = "1.2"

    application_stack {
      dotnet_version = "10.0"
    }
  }

  logs {
    application_logs {
      file_system_level = "Information"
    }
  }

  tags = {
    project = "azure-devops-portfolio"
  }
}

This reference matches the complete main.tf file for this project.

✔️ Awesome, I've got everything!

Your HTTPS hostname output is saved in infra/outputs.tf.

ⓧ I'd like to double check the full code

output "app_url" {
  description = "HTTPS URL of the deployed Azure App Service."
  value       = "https://${azurerm_linux_web_app.portfolio.default_hostname}"
}

This reference matches the complete outputs.tf file for this project.

A Terraform plan previews the exact Azure changes before any resource is created. This is your chance to catch an unexpected SKU, location, or resource count.

  • Generate the infrastructure preview by running this command:
terraform plan

What should the plan contain?

The plan describes one resource group, one Linux Free F1 App Service plan, and one Linux web app. Review the resource names and confirm the web app uses your chosen globally unique name.

You should see three resources scheduled for creation. No existing API files or Azure resources should be changed.

Planning failed?

  • Confirm your Azure CLI session is still signed in.
  • Check that terraform.tfvars contains your real subscription ID and app name.
  • Confirm your selected Azure subscription is active.

Still stuck? Help me troubleshoot terraform plan for Azure

Creating cloud resources can feel risky. This configuration uses the Free F1 App Service tier, which incurs no charge for this lab when you keep the SKU unchanged.

  • Apply the reviewed configuration by running this command:
  • Enter yes when Terraform requests confirmation.
terraform apply

What does terraform apply create?

Terraform authenticates through your Azure CLI session and creates the three resources from the reviewed plan. It stores their identities in local state so later changes and cleanup target the same infrastructure.

Azure needs time to create the host. Let the command continue until Terraform returns the app_url output.

  • Record the printed app_url value here: https://your-app-name.azurewebsites.net.
  • Return to the Azure portal and open the resource group named rg-azure-devops-portfolio.
  • Confirm the resource group lists the web app and the App Service plan named asp-azure-devops-portfolio.

The cloud host is live now. Terraform can reproduce the same resource names, runtime, and security controls from version-controlled configuration.

Terraform apply failed?

  • Check whether Azure reports that your chosen app name is already in use.
  • Replace the app name in terraform.tfvars with another globally unique value if the name is unavailable.
  • Confirm your subscription permits resource creation in West Europe.

Still stuck? Help me troubleshoot terraform apply for my Free F1 web app

Inspect the secured host and test its health route

The Azure resource view now lets you compare the deployed host with the Terraform declarations. Each check below answers whether a required runtime or security control reached Azure.

  • Select the web app whose name matches your globally unique app name.
  • Inspect its general configuration to confirm HTTPS-only access is enabled.
  • Inspect its runtime configuration to confirm .NET 10.0 is selected.
  • Inspect its transport settings to confirm FTPS is disabled.
  • Confirm the app requires minimum TLS 1.2.
  • Confirm the deployment endpoint requires minimum TLS 1.2.
  • Inspect its application logging settings to confirm file-system logging uses the Information level.

The settings confirm Azure built the intended host. The public route check now tests whether that host already contains the API you ran locally.

Before you test it, do you think the local health route already exists on the public host?

  • Paste https://your-app-name.azurewebsites.net into your browser's address bar.
  • Append /health to the URL.
  • Press Enter to request the public health route.

You will see the empty-host placeholder or a 404 response. The public host exists, but the API package has not been deployed to it.

Why is this the intended result?

Terraform created the Azure hosting layer and its security controls. Your local Program.cs still exists only in the project folder.

The missing public health route proves infrastructure provisioning and software delivery are separate responsibilities. That visible gap gives the pipeline a precise job to solve.

Your secured Free F1 host is ready, and its empty health route has exposed the delivery gap. Next, you will build an Azure Pipeline that validates the project and deploys the API to this exact host.

Build and Deploy Through Azure Pipelines

Your Terraform code has produced a secured Azure App Service host. The empty /health response proves that infrastructure alone does not ship your API.

Now Azure Pipelines can turn each commit into a tested release. This CI/CD path validates the infrastructure before deploying the application.

In this step, get ready to:
  • Publish the project files to a private repository.
  • Configure secretless access between the pipeline and Azure.
  • Run the validation stage before deploying the healthy API.
Publish the delivery files to Azure Repos

Your Azure Repos repository becomes the source of truth for the release system. It holds the API plus every instruction needed to validate its infrastructure.

A multi-stage Docker build proves that the API can become a portable image. The Free F1 deployment still uses a ZIP package to keep the cloud path inside the project's cost boundary.

  • Switch back to Visual Studio Code from earlier.
  • Create a file named Dockerfile beside global.json in the Explorer sidebar.
  • Build the portable API image by pasting this code into Dockerfile:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /source

COPY src/PortfolioApi/PortfolioApi.csproj src/PortfolioApi/
RUN dotnet restore src/PortfolioApi/PortfolioApi.csproj

COPY src/PortfolioApi/ src/PortfolioApi/
RUN dotnet publish src/PortfolioApi/PortfolioApi.csproj -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app ./
ENTRYPOINT ["dotnet", "PortfolioApi.dll"]

What does this Dockerfile do?

  • The build stage restores the project before publishing its Release output to /app.
  • The runtime stage copies only the published output into the smaller ASP.NET Core image.
  • The entry point starts PortfolioApi.dll whenever the image runs.
  • Save Dockerfile.
  • Confirm that Dockerfile appears beside global.json in the Explorer sidebar.

Dockerfile missing from Explorer?

Check that the filename is exactly Dockerfile with no extension. Make sure it sits in the same folder as global.json.

Still stuck? Help me place this Dockerfile in the correct project folder.

✔️ Awesome, I've got everything!

Your saved Dockerfile matches the portable build target.

ⓧ I'd like to double check the full code

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /source

COPY src/PortfolioApi/PortfolioApi.csproj src/PortfolioApi/
RUN dotnet restore src/PortfolioApi/PortfolioApi.csproj

COPY src/PortfolioApi/ src/PortfolioApi/
RUN dotnet publish src/PortfolioApi/PortfolioApi.csproj -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app ./
ENTRYPOINT ["dotnet", "PortfolioApi.dll"]

The repository also needs a concise explanation of its delivery flow. The first half of README.md records the tools that the finished project demonstrates.

  • Create README.md beside Dockerfile in the Explorer sidebar.
  • Add the project overview by pasting this first section into README.md:
# Secure Azure CI/CD Portfolio API

## What this proves

This repository demonstrates a practical Azure DevOps delivery path for a .NET API:

- Azure Repos for Git source control
- Terraform for repeatable Azure infrastructure
- Azure Pipelines for validation and deployment
- Docker as a portable build target
- Workload identity federation instead of a stored Azure client secret
- Azure App Service and Azure Monitor for hosting and operations

## Delivery flow

Developer commit -> Azure Repos -> Validate stage -> Docker build -> Release artifact -> Deploy stage -> Azure App Service -> Azure Monitor

## Live demonstration

- API URL: Replace with `https://<app-name>.azurewebsites.net`
- Health route: Replace with `https://<app-name>.azurewebsites.net/health`
- Pipeline run: Replace with your successful Azure Pipeline run link

What does this README section capture?

The opening section connects each project tool to a delivery responsibility. The flow line gives you a short explanation of how a commit reaches Azure.

The live demonstration fields reserve space for evidence from the successful run. You fill those fields during the observability step.

  • Save README.md.
  • Confirm that the editor shows the delivery flow below the tool list.

README headings not rendering clearly?

Check that every heading starts with the same number of hash characters shown in the snippet. Keep a blank line around each heading.

Need help? Help me compare the first half of my README with the required Markdown structure.

The remaining README sections record the security choices behind the release system. They also define the screenshots that complete the portfolio evidence.

  • Place your cursor after the pipeline run placeholder in README.md.
  • Complete the repository explanation by pasting this remaining section:
## Security decisions

- The web app requires HTTPS.
- The app and deployment endpoint require TLS 1.2 or later.
- FTPS is disabled.
- Azure Pipelines authenticates through workload identity federation.
- Terraform variables and local state are excluded from Git.

## Cost and scope decisions

The cloud deployment uses an App Service Free F1 plan. Docker is built as a CI portability check, while the free deployment uses a ZIP package. AKS and a container registry are intentionally deferred because they add cost and operational complexity that do not improve this first portfolio project's core CI/CD lesson.

## Evidence

Add screenshots of:

1. The healthy live API response.
2. The green Validate and Deploy stages.
3. The Docker build task.
4. Azure Monitor metrics and Activity log.
5. The protected pull request from the Secret Mission.

Why record these decisions?

The security section connects each control to the repository that implements it. The cost section explains why the pipeline builds a Docker image while App Service receives a ZIP package.

The evidence checklist turns the repository into a guided demonstration. Each screenshot proves a different part of the delivery path.

  • Save README.md.
  • Confirm that the final line names the protected pull request from the Secret Mission.

README section in the wrong place?

Make sure the security section starts after the pipeline run placeholder. Check that the evidence list ends the file.

Need a second pair of eyes? Help me find where the second README section belongs.

✔️ Awesome, I've got everything!

Your README.md now documents the complete release story.

ⓧ I'd like to double check the full code

# Secure Azure CI/CD Portfolio API

## What this proves

This repository demonstrates a practical Azure DevOps delivery path for a .NET API:

- Azure Repos for Git source control
- Terraform for repeatable Azure infrastructure
- Azure Pipelines for validation and deployment
- Docker as a portable build target
- Workload identity federation instead of a stored Azure client secret
- Azure App Service and Azure Monitor for hosting and operations

## Delivery flow

Developer commit -> Azure Repos -> Validate stage -> Docker build -> Release artifact -> Deploy stage -> Azure App Service -> Azure Monitor

## Live demonstration

- API URL: Replace with `https://<app-name>.azurewebsites.net`
- Health route: Replace with `https://<app-name>.azurewebsites.net/health`
- Pipeline run: Replace with your successful Azure Pipeline run link

## Security decisions

- The web app requires HTTPS.
- The app and deployment endpoint require TLS 1.2 or later.
- FTPS is disabled.
- Azure Pipelines authenticates through workload identity federation.
- Terraform variables and local state are excluded from Git.

## Cost and scope decisions

The cloud deployment uses an App Service Free F1 plan. Docker is built as a CI portability check, while the free deployment uses a ZIP package. AKS and a container registry are intentionally deferred because they add cost and operational complexity that do not improve this first portfolio project's core CI/CD lesson.

## Evidence

Add screenshots of:

1. The healthy live API response.
2. The green Validate and Deploy stages.
3. The Docker build task.
4. Azure Monitor metrics and Activity log.
5. The protected pull request from the Secret Mission.

The pipeline definition now joins source control to the cloud host. Its first section selects main as the trigger before preparing the validation job.

  • Create azure-pipelines.yml beside README.md in the Explorer sidebar.
  • Define the trigger plus the validation job by pasting this first pipeline section:
trigger:
- main

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  azureServiceConnection: 'portfolio-azure-wif'

stages:
- stage: Validate
  displayName: 'Validate and package'
  jobs:
  - job: Quality
    displayName: 'Infrastructure and application quality gates'
    steps:
    - task: UseDotNet@2
      displayName: 'Install .NET SDK 10.0.402'
      inputs:
        packageType: 'sdk'
        version: '10.0.402'

How does the pipeline begin?

  • The trigger starts the pipeline when a commit reaches main.
  • The pool selects a Microsoft-hosted Ubuntu agent.
  • The variables keep the build mode plus the service connection name in one place.
  • The first task installs the exact .NET SDK required by global.json.
  • Save azure-pipelines.yml.
  • Confirm that the file starts with the main trigger.

YAML indentation looks uneven?

Keep each nested level aligned exactly as shown. Use spaces because YAML structure depends on indentation.

Need help checking the first section? Help me inspect the indentation in my pipeline trigger and validation job.

The next quality gates validate the Terraform configuration before compiling the API. This catches an invalid infrastructure change before a deployment can start.

  • Place your cursor after the .NET SDK installation task.
  • Add the Terraform validation plus the .NET restore and build tasks:
    - task: TerraformInstaller@1
      displayName: 'Install Terraform 1.17.0'
      inputs:
        terraformVersion: '1.17.0'

    - bash: |
        cd infra
        terraform init
        terraform validate
      displayName: 'Validate Terraform configuration'

    - task: DotNetCoreCLI@2
      displayName: 'Restore .NET project'
      inputs:
        command: 'restore'
        projects: 'src/PortfolioApi/PortfolioApi.csproj'

    - task: DotNetCoreCLI@2
      displayName: 'Build .NET project'
      inputs:
        command: 'build'
        projects: 'src/PortfolioApi/PortfolioApi.csproj'
        arguments: '--configuration $(buildConfiguration) --no-restore'

What do these quality gates prove?

  • The installer pins Terraform to 1.17.0 on the hosted agent.
  • The Bash task initializes the provider before checking the configuration syntax.
  • The restore task downloads the .NET project dependencies.
  • The build task compiles the API in Release mode using the restored dependencies.
  • Save azure-pipelines.yml.
  • Confirm that the validation job now ends with the .NET build task.

Validation tasks outside the job?

Align each task marker with the existing .NET SDK task marker. Keep every task beneath the same steps key.

Need help with the nesting? Help me align the Terraform and .NET tasks inside the Quality job.

A successful application build can now feed two release formats. The Docker task proves image portability before the publish task prepares the App Service files.

  • Place your cursor after the .NET build task.
  • Add the Docker image build plus the .NET publish task:
    - task: Docker@2
      displayName: 'Build Docker image'
      inputs:
        command: 'build'
        repository: 'portfolio-api'
        Dockerfile: '$(Build.SourcesDirectory)/Dockerfile'
        buildContext: '$(Build.SourcesDirectory)'
        tags: '$(Build.BuildId)'

    - task: DotNetCoreCLI@2
      displayName: 'Publish .NET application'
      inputs:
        command: 'publish'
        publishWebProjects: false
        projects: 'src/PortfolioApi/PortfolioApi.csproj'
        arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)/publish --no-restore'
        zipAfterPublish: false
        modifyOutputPath: false

Why create two build outputs?

The Docker task builds the portable portfolio-api image from the repository's Dockerfile. Its tag ties the image to the pipeline run.

The publish task creates the files used by the Free F1 deployment. This keeps image portability separate from the ZIP release path.

  • Save azure-pipelines.yml.
  • Confirm that the Docker build appears before the .NET publish task.

Docker task cannot find the file?

Confirm that Dockerfile sits beside azure-pipelines.yml.

Need help tracing the path? Help me compare the Docker task paths with my project layout.

The published output must become a pipeline artifact before another stage can use it. The archive tasks create that handoff as a file named portfolio-api.zip.

  • Place your cursor after the .NET publish task.
  • Package the published output by adding these artifact tasks:
    - task: ArchiveFiles@2
      displayName: 'Create deployment ZIP'
      inputs:
        rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/publish'
        includeRootFolder: false
        archiveType: 'zip'
        archiveFile: '$(Build.ArtifactStagingDirectory)/portfolio-api.zip'
        replaceExistingArchive: true

    - task: PublishPipelineArtifact@1
      displayName: 'Publish deployment artifact'
      inputs:
        targetPath: '$(Build.ArtifactStagingDirectory)/portfolio-api.zip'
        artifact: 'drop'
        publishLocation: 'pipeline'

How does the artifact handoff work?

The archive task places the contents of the publish directory directly inside the ZIP. App Service can extract that layout as the application release.

The publish task stores the ZIP as an artifact named drop. The deployment stage downloads that exact artifact.

  • Save azure-pipelines.yml.
  • Confirm that the Validate stage now ends with the published drop artifact.

Archive task nested under publish?

Align both task markers with the earlier Docker task marker. Keep the inputs for each task indented beneath that task.

Need help separating the tasks? Help me check the archive and artifact indentation.

The deployment stage consumes only the artifact that passed every quality gate. Its branch condition keeps feature-branch runs away from the live App Service.

  • Place your cursor after the artifact publishing task.
  • Complete the pipeline by adding the deployment stage:
- stage: Deploy
  displayName: 'Deploy to Azure App Service'
  dependsOn: Validate
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - job: DeployWebApp
    displayName: 'Deploy validated artifact'
    steps:
    - task: DownloadPipelineArtifact@2
      displayName: 'Download deployment artifact'
      inputs:
        buildType: 'current'
        artifactName: 'drop'
        targetPath: '$(Pipeline.Workspace)/drop'

    - task: AzureWebApp@1
      displayName: 'Deploy .NET API'
      inputs:
        azureSubscription: '$(azureServiceConnection)'
        appType: 'webAppLinux'
        appName: '$(azureAppName)'
        package: '$(Pipeline.Workspace)/drop/portfolio-api.zip'
        runtimeStack: 'DOTNETCORE|10.0'

How is deployment protected?

  • The dependency requires the Validate stage to finish before deployment starts.
  • The condition allows deployment only after success on refs/heads/main.
  • The download task retrieves the drop artifact from the current run.
  • The App Service task uses the federated connection plus the pipeline's app-name variable.
  • Save azure-pipelines.yml.
  • Confirm that the final line pins the runtime stack to DOTNETCORE|10.0.

Deploy stage appears inside Validate?

Align the Deploy stage marker with the Validate stage marker. Both stage markers start at the same indentation level.

Need help locating the boundary? Help me separate the Validate and Deploy stages in my YAML pipeline.

✔️ Awesome, I've got everything!

Your saved azure-pipelines.yml contains both release stages.

ⓧ I'd like to double check the full code

trigger:
- main

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  azureServiceConnection: 'portfolio-azure-wif'

stages:
- stage: Validate
  displayName: 'Validate and package'
  jobs:
  - job: Quality
    displayName: 'Infrastructure and application quality gates'
    steps:
    - task: UseDotNet@2
      displayName: 'Install .NET SDK 10.0.402'
      inputs:
        packageType: 'sdk'
        version: '10.0.402'

    - task: TerraformInstaller@1
      displayName: 'Install Terraform 1.17.0'
      inputs:
        terraformVersion: '1.17.0'

    - bash: |
        cd infra
        terraform init
        terraform validate
      displayName: 'Validate Terraform configuration'

    - task: DotNetCoreCLI@2
      displayName: 'Restore .NET project'
      inputs:
        command: 'restore'
        projects: 'src/PortfolioApi/PortfolioApi.csproj'

    - task: DotNetCoreCLI@2
      displayName: 'Build .NET project'
      inputs:
        command: 'build'
        projects: 'src/PortfolioApi/PortfolioApi.csproj'
        arguments: '--configuration $(buildConfiguration) --no-restore'

    - task: Docker@2
      displayName: 'Build Docker image'
      inputs:
        command: 'build'
        repository: 'portfolio-api'
        Dockerfile: '$(Build.SourcesDirectory)/Dockerfile'
        buildContext: '$(Build.SourcesDirectory)'
        tags: '$(Build.BuildId)'

    - task: DotNetCoreCLI@2
      displayName: 'Publish .NET application'
      inputs:
        command: 'publish'
        publishWebProjects: false
        projects: 'src/PortfolioApi/PortfolioApi.csproj'
        arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)/publish --no-restore'
        zipAfterPublish: false
        modifyOutputPath: false

    - task: ArchiveFiles@2
      displayName: 'Create deployment ZIP'
      inputs:
        rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/publish'
        includeRootFolder: false
        archiveType: 'zip'
        archiveFile: '$(Build.ArtifactStagingDirectory)/portfolio-api.zip'
        replaceExistingArchive: true

    - task: PublishPipelineArtifact@1
      displayName: 'Publish deployment artifact'
      inputs:
        targetPath: '$(Build.ArtifactStagingDirectory)/portfolio-api.zip'
        artifact: 'drop'
        publishLocation: 'pipeline'

- stage: Deploy
  displayName: 'Deploy to Azure App Service'
  dependsOn: Validate
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - job: DeployWebApp
    displayName: 'Deploy validated artifact'
    steps:
    - task: DownloadPipelineArtifact@2
      displayName: 'Download deployment artifact'
      inputs:
        buildType: 'current'
        artifactName: 'drop'
        targetPath: '$(Pipeline.Workspace)/drop'

    - task: AzureWebApp@1
      displayName: 'Deploy .NET API'
      inputs:
        azureSubscription: '$(azureServiceConnection)'
        appType: 'webAppLinux'
        appName: '$(azureAppName)'
        package: '$(Pipeline.Workspace)/drop/portfolio-api.zip'
        runtimeStack: 'DOTNETCORE|10.0'

The repository can now publish a complete release definition. Your existing .gitignore keeps the local Terraform variables plus state outside that history.

  • Return to the AzureDevOpsPortfolio project in Azure DevOps.
  • Select Repos in the left navigation.
  • Open the repository selector at the top of the Repos page.
  • Create an empty repository named azure-devops-portfolio.
  • Leave the repository without generated starter files.
  • Copy the repository's clone URL into your Azure Repos clone URL.

The next command records the project as one commit before publishing main. Git may open an Azure DevOps authentication flow during the first push.

  • Use the integrated terminal for the folder that contains global.json.
  • Initialize the repository plus publish main by running these commands:
git init
git add .
git commit -m "first commit"
git branch -m main
git remote add origin [[AZURE_REPOS_URL="your Azure Repos clone URL"]]
git status
git push -u origin main

What do these Git commands do?

  • The first three commands create local history from the project files.
  • The branch command names the release branch main.
  • The remote command connects the local history to the empty Azure Repo.
  • The final command publishes the commit plus records the upstream branch.
  • Complete the Azure DevOps sign-in flow if Git requests authentication.
  • Refresh the azure-devops-portfolio repository page after the push finishes.
  • Confirm that Dockerfile appears in the repository.
  • Confirm that README.md appears in the repository.
  • Confirm that azure-pipelines.yml appears in the repository.
  • Confirm that infra/terraform.tfvars is absent from the repository.
  • Confirm that Terraform state files are absent from the repository.

Push rejected or sensitive files visible?

A rejected push often means the remote repository contains a generated starter commit. Recreate it as an empty repository before repeating the publish flow.

If local Terraform files appear in Azure Repos, stop before continuing. Confirm that the provided .gitignore remains unchanged.

Need help with the repository state? Help me troubleshoot my first Azure Repos push without publishing Terraform state.

Configure the federated Azure connection

The hosted pipeline needs permission to deploy into your subscription. Workload identity federation provides that access through an identity exchange instead of a stored Azure client secret.

This connection touches subscription permissions. Its scope stays limited to the subscription used by this project.

  • Open the Terraform extension by Microsoft DevLabs in your browser.
  • Use the Marketplace installation control to select your Azure DevOps organization.
  • Complete the installation for that organization.
  • Return to the AzureDevOpsPortfolio project.
  • Open Project settings from the lower left corner.
  • Select Service connections.
  • Select New service connection.
  • Choose Azure Resource Manager.

Why use workload identity federation?

The service connection exchanges pipeline identity for short-lived Azure access. This keeps a reusable client secret out of the pipeline settings.

The pipeline still receives only the scope selected during connection setup. Subscription scope matches the Terraform resources in this project.

  • Choose App registration (automatic).
  • Choose Workload identity federation as the authentication method.
  • Set the scope level to your Azure subscription.
  • Select the subscription that contains rg-azure-devops-portfolio.
  • Enter portfolio-azure-wif as the service connection name.
  • Leave access for every pipeline disabled.
  • Save the service connection.

Cannot create the service connection?

Confirm that the selected subscription is the one linked to your Azure DevOps organization. Automatic app registration also requires sufficient subscription permissions.

If the Terraform task is unavailable later, confirm that the Microsoft DevLabs extension is installed for the same organization.

Need help checking access? Help me diagnose my automatic workload-identity service connection setup.

The deployment task also needs the exact App Service name created by Terraform. This value is configuration data instead of a credential.

  • Switch back to infra/terraform.tfvars in Visual Studio Code.
  • Copy only the value assigned to app_name into your globally unique App Service name.
  • Return to the pipeline configuration in Azure DevOps.
Run the pipeline and deploy the API

Azure DevOps can now read the YAML file from main. The app-name variable gives the deployment task its exact destination.

  • Select Pipelines in the Azure DevOps left navigation.
  • Select New pipeline.
  • Choose Azure Repos Git as the code location.
  • Select the azure-devops-portfolio repository.
  • Choose the option for an existing Azure Pipelines YAML file.
  • Select main as the branch.
  • Select /azure-pipelines.yml as the path.
  • Continue to the pipeline review screen.

The YAML expects a pipeline variable named azureAppName. Its value connects the reusable pipeline definition to your unique web app.

  • Open the pipeline variables control from the review screen.
  • Add a variable named azureAppName.
  • Set its value to your globally unique App Service name.
  • Leave the secret setting disabled.
  • Save the pipeline variable.

Before you start the run, which stage do you expect to finish before Azure receives the ZIP package?

  • Start the pipeline from the review screen.
  • Approve access to portfolio-azure-wif only when the run requests it.
  • Leave broad access for every pipeline disabled.
  • Queue the run again if the original run remains stopped after authorization.

The first run can take several minutes while the hosted agent installs the pinned tools. You will see the Validate stage process Terraform plus the .NET and Docker builds before Deploy starts.

  • Open the completed pipeline run.
  • Confirm that the Validate stage is green.
  • Expand the validation job to confirm that the Docker image build succeeded.
  • Confirm that the Deploy stage is green.

That is the release path working. Your commit has passed infrastructure validation before reaching the live App Service.

Pipeline run is not green?

  • If the Terraform installer task is unavailable, confirm that the Microsoft DevLabs extension is installed in the same Azure DevOps organization.
  • If Azure access is blocked, authorize only portfolio-azure-wif for this pipeline.
  • If deployment cannot find the web app, compare azureAppName with the app_name value in infra/terraform.tfvars.

Need help reading the failed task? Help me diagnose my Azure Pipeline from the first red task.

Before you refresh the live health route, what response do you expect now that the deployment stage is green?

  • Return to the App Service /health page from the previous step.
  • Refresh the page.

You will see {"status":"healthy"} over HTTPS. The pipeline has replaced the empty host with your running API.

Health route still shows the placeholder?

Confirm that the Deploy stage completed after the Validate stage. Check that azureAppName matches the web app created by Terraform.

A cached placeholder can survive briefly in the browser. Refresh the HTTPS health URL after the deployment reports success.

Still stuck? Help me trace a green pipeline that has not updated my App Service health route.

Your delivery path is live. Next, you will prove its security controls plus inspect the operational trail in Azure Monitor.

Prove Security, Health, and Observability

Your green Azure Pipelines run proves that the API moved from source control to Azure App Service. A production release still needs evidence of health, security, and operational visibility.

Now you'll verify the live service from a user's perspective. You'll use Azure Monitor to trace what happened after deployment.

In this step, get ready to:
  • Verify the live API endpoints.
  • Capture deployment evidence.
  • Inspect operational evidence.
Verify the live API and pipeline

The live routes prove that the deployed artifact responds outside the build system. The pipeline run provides a separate record of the quality gates that approved the release.

  • Enter the live root URL from earlier in your browser address bar.

You'll see JSON containing {"service":"portfolio-api","status":"running"}. This response identifies the deployed service.

Before you check the health route, predict whether it reports the same status as the root route.

  • Open the live /health route from earlier.

You'll see {"status":"healthy"}. You now have live proof that App Service is serving the deployed health contract.

The pipeline supplies traceable evidence for the release. Its Docker build also proves that the API can be packaged as an image.

  • Switch back to Azure DevOps from earlier.
  • Select the successful pipeline run for main.
  • Confirm that the Validate stage is green.
  • Confirm that the Deploy stage is green.
  • Open the Validate stage.
  • Select the Build Docker image task.

You'll see successful task output from the image build. This connects the green pipeline result to the Dockerfile stored in your repository.

  • Take a screenshot of the healthy /health response.
  • Take a screenshot showing the green pipeline stages.
  • Take a screenshot showing the successful Build Docker image task.
Inspect Azure Monitor evidence

Platform metrics show how the hosting service behaves over time. Application output explains what the API reported during startup.

  • Switch back to the existing Azure App Service resource in the Azure portal.
  • Open the app's platform metrics in its monitoring experience.

You'll see automatically collected measurements for the running web app. These measurements provide the service-level view of your deployment.

  • Select a request-related metric.

You'll see activity associated with requests to the web app. This proves that Azure records traffic separately from the API response itself.

  • Switch back to the live root endpoint.
  • Refresh the root endpoint.

You'll see the running service response again. This creates another request for the platform metrics.

  • Switch to the live /health endpoint.
  • Refresh the health endpoint.

You'll see the healthy response again. The health check now contributes another request to your operational evidence.

  • Return to the platform metrics view.
  • Refresh the metrics view.

You'll see request activity from your endpoint checks. The metric proves that the platform observed traffic reaching the deployed app.

  • Return to the web app's monitoring experience.
  • Open Log stream.
  • Locate Portfolio API started in the application output.

The startup message comes from app.Logger.LogInformation in Program.cs. Seeing it proves that App Service captured output from the deployed API.

Can't see the startup log?

  • Confirm that Log stream belongs to the same Linux web app deployed by the pipeline.
  • Reload Log stream after the application starts.

Still stuck? Help me find my App Service startup message.

  • Open Activity log for the web app.
  • Review the entries associated with resource creation.
  • Review the entries associated with deployment operations.
  • Take a screenshot of the request activity in the platform metrics.
  • Take a screenshot of the relevant Activity log entries.

You'll see a history of Azure operations affecting the resource. Together, the metrics, application output, and activity history show what the service received and what Azure changed.

Review your evidence and security story

Your README.md turns the technical work into a structured demonstration. Its existing placeholders show where the live URL, pipeline link, and screenshots belong in your evidence story.

  • Switch back to Azure Repos from earlier.
  • Select README.md from the repository file list.
  • Review the Live demonstration section.
  • Match the API URL placeholder to your verified root endpoint.
  • Match the health route placeholder to your verified /health endpoint.
  • Match the pipeline run placeholder to your successful main pipeline run link.

Each placeholder now maps to evidence you verified yourself. That makes every claim in the demonstration traceable.

  • Review the Evidence section.
  • Confirm that its first four items map to the screenshots captured during the main project.

Security Choices You Can Explain

Each control closes a specific delivery or transport risk. Use these points when explaining the project.

  • Workload identity federation lets Azure Pipelines authenticate to Azure without a stored client secret.
  • HTTPS-only access keeps public API requests on an encrypted connection.
  • TLS 1.2 sets the minimum transport security version for the app and deployment endpoint.
  • FTPS is disabled because the pipeline deploys through its workload-identity service connection.
  • Explain how workload identity federation removes stored Azure client secrets from the pipeline.
  • Explain how HTTPS-only access protects public API traffic.
  • Explain why the minimum TLS version is 1.2.
  • Explain why the App Service configuration disables FTPS.

Before the final check, predict whether your latest health request appears in the app's operational evidence.

  • Refresh the live /health endpoint.

You'll see {"status":"healthy"} again. The public service remains healthy after your monitoring review.

  • Return to the platform metrics.
  • Refresh the metrics view.

You'll see request activity associated with your endpoint checks. This confirms that Azure Monitor is observing the running application.

  • Open Activity log again.

You'll see resource or deployment operations from the project. That's the release story complete: the API is healthy, the security controls are explainable, and the operational evidence is visible.

Secret mission

Protect `main` with a Failing Pull Request

Turn main into a protected branch with required pipeline validation. You will watch an invalid Terraform change turn the policy red. Then you will repair the change and complete the pull request only after the policy turns green.

Clean Up Your Resources

Clean Up Your Resources

This project stays within its $0 design while the live Azure App Service plan remains on F1. Decide whether to keep the demo available, pause its web app, or remove the Terraform-managed Azure resources.

Cost warning

Your private project receives one Microsoft-hosted Azure DevOps job for up to 1,800 minutes each month after the free tier is enabled. Changes outside this design can introduce charges.

  • Keep the App Service plan on F1.
  • Check current prices before adding Application Insights.
  • Check current prices before adding Log Analytics.
  • Check current prices before creating unrelated Azure resources.

Resources you used:

  • Azure resource group rg-azure-devops-portfolio.
  • Linux App Service plan asp-azure-devops-portfolio on the Free F1 tier.
  • Linux web app managed by azurerm_linux_web_app.portfolio.
  • Local Terraform working data under infra/.terraform/.
  • Local Terraform values in infra/terraform.tfvars.
  • Local Terraform state matching infra/*.tfstate.

Keep everything running

No action needed. Choose this if you want the live API and its delivery evidence available for future demonstrations.

  • Keep the Free F1 web app available as your live portfolio demo.
  • Preserve the infra folder with its original local Terraform state.
  • Retain the private Azure Repos repository as your source history.
  • Retain the successful Azure Pipelines run as delivery evidence.
  • Retain the workload identity federation service connection as evidence of secretless authentication.
  • Keep pipeline usage within the monthly free allowance.

Pause - I'll come back to this later

Pausing stops the running web app while preserving its Azure configuration. Your repository and local Terraform state remain ready for a later restart.

  • Return to the live Portfolio API web app in the Azure portal to stop its runtime.
  • Use the web app's stop control.
  • Refresh the live health URL.

You'll see that the endpoint no longer returns the healthy JSON response. This confirms the web app has stopped.

  • Leave the infra folder unchanged.
  • Keep the Azure Repos repository available.
  • Keep the pipeline configuration available.
  • Keep the workload-identity service connection available.

Delete - I don't want to use this again

This option removes the live Azure deployment. Your Azure DevOps evidence stays available for future demonstrations.

Removing the live host is a deliberate step. Terraform shows every planned deletion before asking you to confirm it.

  • Return to the same infra folder that contains main.tf and the original local Terraform state.
  • Start the Azure resource cleanup by running this command:
terraform destroy

What does this cleanup remove?

Terraform reads the local state to identify the Azure resources it manages. It proposes deleting the Linux web app, the App Service plan, and the resource group.

  • Review the destruction plan for the three managed Azure resources.
  • Enter yes when Terraform asks for confirmation.

That's the live infrastructure safely removed. Your repository and portfolio evidence remain untouched.

  • Refresh the former Portfolio API health URL.

You'll see that the health endpoint no longer loads. This confirms the public web app has been removed.

  • Return to the Azure portal to confirm that rg-azure-devops-portfolio is no longer present.

You should no longer see the Terraform-managed resource group. Its App Service plan and web app were removed with it.

Terraform cannot destroy the resources?

  • Confirm that your terminal is inside the infra folder.
  • Confirm that the original local Terraform state is still present.
  • Reauthenticate through Azure CLI if your Azure session expired.

Help me diagnose why Terraform cannot destroy my Azure resources.

The successful destroy leaves generated local Terraform data behind. You can remove that ignored data while keeping the tracked infrastructure code.

  • Remove the generated Terraform data from infra by running this command:
rm -rf .terraform .terraform.lock.hcl terraform.tfvars terraform.tfstate terraform.tfstate.* tfplan

What does this local cleanup remove?

This command deletes Terraform's downloaded working data, local values, saved plans, and local state. It keeps main.tf, variables.tf, outputs.tf, and terraform.tfvars.example.

  • Inspect the infra folder in your file browser.

You'll still see the tracked Terraform configuration files. The ignored working data and local state are gone.

Still see generated Terraform files?

  • Confirm that the terminal is still inside the infra folder.
  • Confirm that your Linux user owns the generated Terraform files.

Help me remove leftover Terraform files safely.

  • Keep the Azure Repos repository as portfolio evidence.
  • Keep the successful pipeline run as portfolio evidence.
  • Keep the Azure Monitor screenshots as portfolio evidence.
  • Keep the protected pull request screenshots as portfolio evidence.

Nice Work!

Nice Work!

You made it! Your Portfolio API now moves from Azure Repos to a secured Azure App Service through an automated release pipeline.

You've learned how to:

  • Build an ASP.NET Core 10.0 Portfolio API that serves JSON routes. Use /health to prove its health contract. Reproduce its secured Free F1 Linux host with Terraform. Demonstrate HTTPS-only access. Show a minimum TLS version of 1.2. Show disabled FTPS.
  • Move updates from Azure Repos into a multi-stage Azure Pipelines workflow. Validate infrastructure. Build the .NET project. Prove Docker portability. Deploy the ZIP artifact through workload identity federation.
  • Use Azure Monitor to connect a green deployment to operational evidence. Inspect platform metrics. Find the startup message in Log stream. Review deployment activity in Activity log. Capture the live health response for your portfolio.
  • Secret Mission: Protect main with required build validation. Trigger a failing pull request by removing a Terraform brace. Repair the configuration. Complete the pull request after the policy turns green.

Ready to quiz yourself?