n4nAI

Semantic Kernel .NET tutorial: Azure deployment walkthrough

Deploy a Semantic Kernel .NET app to Azure with Bicep, Container Apps, and GitHub Actions — complete working code and verification steps.

n4n Team3 min read620 words

Audio narration

Coming soon — every post will get a voice note here.

You’ve built a Semantic Kernel application locally. Now you need it running in Azure with proper infrastructure, secrets management, and a deployment pipeline. This tutorial walks through the complete path from repository to production Container App, using Bicep for infrastructure and GitHub Actions for CI/CD. Every code block is runnable; expected outputs are shown at each checkpoint.

Prerequisites

Before starting, ensure you have:

  • .NET 8 SDK installed (dotnet --version returns 8.0.x)
  • Azure CLI 2.53+ (az --version)
  • Bicep CLI (az bicep install)
  • Docker Desktop or Podman for local container builds
  • A GitHub repository with Actions enabled
  • An Azure subscription with permission to create resource groups, Container Apps, Container Registries, Log Analytics workspaces, and Key Vaults

Verify your environment:

dotnet --version
# 8.0.402

az --version | head -1
# azure-cli 2.57.0 ...

az bicep version
# Bicep CLI version 0.25.3 ...

Initialize the project structure

Create a solution with two projects: the API and an infrastructure folder for Bicep.

mkdir sk-azure-deploy && cd sk-azure-deploy
dotnet new sln -n SkAzureDeploy
dotnet new webapi -n SkAzureDeploy.Api -o src/SkAzureDeploy.Api
dotnet sln add src/SkAzureDeploy.Api/SkAzureDeploy.Api.csproj
mkdir infra

Add the Semantic Kernel packages to the API project:

cd src/SkAzureDeploy.Api
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.AzureOpenAI
dotnet add package Microsoft.Extensions.Azure
dotnet add package Azure.Identity
dotnet add package Azure.Security.KeyVault.Secrets
cd ../..

Expected src/SkAzureDeploy.Api/SkAzureDeploy.Api.csproj snippet:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.SemanticKernel" Version="1.25.0" />
    <PackageReference Include="Microsoft.SemanticKernel.Connectors.AzureOpenAI" Version="1.25.0" />
    <PackageReference Include="Microsoft.Extensions.Azure" Version="1.8.0" />
    <PackageReference Include="Azure.Identity" Version="1.12.0" />
    <PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.6.0" />
  </ItemGroup>
</Project>

Configure Semantic Kernel with Azure OpenAI

Replace Program.cs with a minimal kernel setup that reads configuration from Key Vault at startup.

// src/SkAzureDeploy.Api/Program.cs
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Azure;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

var builder = WebApplication.CreateBuilder(args);

// Add Azure clients with managed identity
builder.Services.AddAzureClients(clientBuilder =>
{
    clientBuilder.AddSecretClient(new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"));
    clientBuilder.UseCredential(new DefaultAzureCredential());
});

// Register Semantic Kernel with Azure OpenAI
builder.Services.AddKernel()
    .AddAzureOpenAIChatCompletion(
        deploymentName: builder.Configuration["AzureOpenAI:ChatDeployment"] ?? "gpt-4o-mini",
        endpoint: builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("AzureOpenAI:Endpoint required"),
        apiKey: builder.Configuration["AzureOpenAI:ApiKey"] ?? throw new InvalidOperationException("AzureOpenAI:ApiKey required"));

// Resolve secrets from Key Vault into configuration
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
    var secretClient = scope.ServiceProvider.GetRequiredService<SecretClient>();
    var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
    
    // In production, cache these; here we fetch once at startup
    var endpointSecret = await secretClient.GetSecretAsync("azure-openai-endpoint");
    var apiKeySecret = await secretClient.GetSecretAsync("azure-openai-key");
    var deploymentSecret = await secretClient.GetSecretAsync("azure-openai-chat-deployment");
    
    // Re-bind so kernel picks them up (simplified for tutorial)
    Environment.SetEnvironmentVariable("AzureOpenAI__Endpoint", endpointSecret.Value.Value);
    Environment.SetEnvironmentVariable("AzureOpenAI__ApiKey", apiKeySecret.Value.Value);
    Environment.SetEnvironmentVariable("AzureOpenAI__ChatDeployment", deploymentSecret.Value.Value);
}

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

app.MapPost("/chat", async (ChatRequest request, Kernel kernel) =>
{
    var result = await kernel.InvokePromptAsync(request.Message);
    return Results.Ok(new { response = result.ToString() });
});

app.Run();

record ChatRequest(string Message);

Add a Dockerfile at the repository root:

# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/SkAzureDeploy.Api/SkAzureDeploy.Api.csproj", "src/SkAzureDeploy.Api/"]
RUN dotnet restore "src/SkAzureDeploy.Api/SkAzureDeploy.Api.csproj"
COPY . .
WORKDIR "/src/src/SkAzureDeploy.Api"
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "SkAzureDeploy.Api.dll"]

Test locally (without Key Vault — uses environment variables):

cd src/SkAzureDeploy.Api
export AzureOpenAI__Endpoint="https://your-resource.openai.azure.com"
export AzureOpenAI__ApiKey="your-key"
export AzureOpenAI__ChatDeployment="gpt-4o-mini"
dotnet run

Expected output:

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5001

Verify the endpoint:

curl -X POST http://localhost:5000/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "Say hello from Semantic Kernel"}'
{"response":"Hello from Semantic Kernel! How can I help you today?"}

Define infrastructure with Bicep

Create infra/main.bicep — a complete, production-ready deployment.

// infra/main.bicep
@description('Unique suffix for resource names')
param uniqueSuffix string = uniqueString(resourceGroup().id)

@description('Location for all resources')
param location string = resourceGroup().location

@description('Azure OpenAI resource name (existing)')
param openAiResourceName string

@description('Azure OpenAI resource group (existing)')
param openAiResourceGroup string

@description('Container App Environment name')
param containerAppEnvName string = 'cae-${uniqueSuffix}'

@description('Container Registry SKU')
param acrSku string = 'Basic'

@description('Log Analytics retention days')
param logRetentionDays int = 30

// --- Resources ---

resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
  name: 'law-${uniqueSuffix}'
  location: location
  properties: {
    retentionInDays: logRetentionDays
    sku: { name: 'PerGB2018' }
  }
}

resource containerAppEnv 'Microsoft.App/managedEnvironments@2023-05-01' = {
  name: containerAppEnvName
  location: location
  properties: {
    logAnalytics: {
      workspaceId: logAnalytics.properties.workspaceId
      workspaceKey: logAnalytics.listKeys().primarySharedKey
    }
  }
}

resource acr 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' = {
  name: 'acr${unique(uniqueSuffix)}'
  location: location
  sku: { name: acrSku }
  properties: {
    adminUserEnabled: false
    publicNetworkAccess: 'Enabled'
  }
}

resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: 'kv-${uniqueSuffix}'
  location: location
  properties: {
    tenantId: subscription().tenantId
    sku: { family: 'A', name: 'standard' }
    enableRbacAuthorization: true
    publicNetworkAccess: 'Enabled'
    networkAcls: {
      defaultAction: 'Allow'
      bypass: 'AzureServices'
    }
  }
}

// Grant the deployment identity (or your user) Key Vault Secrets Officer
// In CI/CD, the GitHub OIDC identity gets this role via a separate assignment

resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
  name: 'ca-sk-${uniqueSuffix}'
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    managedEnvironmentId: containerAppEnv.id
    configuration: {
      ingress: {
        external: true
        targetPort: 8080
        allowInsecure: false
        traffic: [{ weight: 100, latestRevision: true }]
      }
      registries: [{
        server: acr.properties.loginServer
        identity: 'system'
      }]
      secrets: [
        { name: 'azure-openai-endpoint', keyVaultUrl: '${keyVault.properties.vaultUri}secrets/azure-openai-endpoint' }
        { name: 'azure-openai-key', keyVaultUrl: '${keyVault.properties.vaultUri}secrets/azure-openai-key' }
        { name: 'azure-openai-chat-deployment', keyVaultUrl: '${keyVault.properties.vaultUri}secrets/azure-openai-chat-deployment' }
      ]
    }
    template: {
      containers: [{
        name: 'sk-api'
        image: '${acr.properties.loginServer}/sk-azure-deploy:latest'
        resources: { cpu: 0.5, memory: '1Gi' }
        env: [
          { name: 'KeyVaultName', value: keyVault.name }
          { name: 'AzureOpenAI__Endpoint', secretRef: 'azure-openai-endpoint' }
          { name: 'AzureOpenAI__ApiKey', secretRef: 'azure-openai-key' }
          { name: 'AzureOpenAI__ChatDeployment', secretRef: 'azure-openai-chat-deployment' }
          { name: 'ASPNETCORE_URLS', value: 'http://+:8080' }
        ]
      }]
      scale: {
        minReplicas: 1
        maxReplicas: 10
        rules: [{
          name: 'http-scaling'
          http: { metadata: { concurrentRequests: '50' } }
        }]
      }
    }
  }
}

// Outputs for GitHub Actions
output containerAppName string = containerApp.name
output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}'
output acrLoginServer string = acr.properties.loginServer
output keyVaultName string = keyVault.name

Deploy the infrastructure:

cd infra
az deployment group create \
  --resource-group rg-sk-demo \
  --template-file main.bicep \
  --parameters openAiResourceName=my-openai \
  --parameters openAiResourceGroup=rg-openai \
  --parameters location=eastus

Expected output (truncated):

{
  "properties": {
    "outputs": {
      "containerAppName": { "type": "String", "value": "ca-sk-abc123" },
      "containerAppUrl": { "type": "String", "value": "https://ca-sk-abc123.eastus.azurecontainerapps.io" },
      "acrLoginServer": { "type": "String", "value": "acrabc123.azurecr.io" },
      "keyVaultName": { "type": "String", "value": "kv-abc123" }
    },
    "provisioningState": "Succeeded"
  }
}

Populate Key Vault with secrets

The Bicep deployment creates the Key Vault but doesn’t populate secrets. Do this once (or script it in your pipeline):

KV_NAME=$(az deployment group show -g rg-sk-demo -n main --query properties.outputs.keyVaultName.value -o tsv)

az keyvault secret set --vault-name $KV_NAME --name azure-openai-endpoint --value "https://my-openai.openai.azure.com"
az keyvault secret set --vault-name $KV_NAME --name azure-openai-key --value "your-azure-openai-key"
az keyvault secret set --vault-name $KV_NAME --name azure-openai-chat-deployment --value "gpt-4o-mini"

Grant your user (or the GitHub Actions OIDC identity) Key Vault Secrets Officer on the vault:

az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee $(az ad signed-in-user show --query id -o tsv) \
  --scope $(az keyvault show -n $KV_NAME --query id -o tsv)

Build and push the container image

Use the Azure Container Registry from the Bicep output:

ACR_LOGIN_SERVER=$(az deployment group show -g rg-sk-demo -n main --query properties.outputs.acrLoginServer.value -o tsv)

az acr login --name ${ACR_LOGIN_SERVER%.azurecr.io}
docker build -t ${ACR_LOGIN_SERVER}/sk-azure-deploy:latest .
docker push ${ACR_LOGIN_SERVER}/sk-azure-deploy:latest

Expected output:

The push refers to repository [acrabc123.azurecr.io/sk-azure-deploy]
latest: digest: sha256:abcdef123456... size: 1234

Trigger a Container App revision update (or wait for the next pipeline run):

CONTAINER_APP=$(az deployment group show -g rg-sk-demo -n main --query properties.outputs.containerAppName.value -o tsv)
az containerapp update -n $CONTAINER_APP -g rg-sk-demo --image ${ACR_LOGIN_SERVER}/sk-azure-deploy:latest

Verify the deployment

APP_URL=$(az deployment group show -g rg-sk-demo -n main --query properties.outputs.containerAppUrl.value -o tsv)

curl -X POST ${APP_URL}/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "Explain Semantic Kernel in one sentence"}'

Expected response:

{"response":"Semantic Kernel is an open-source SDK that lets you integrate AI models like OpenAI into your applications with plugins, planning, and memory."}

Check health endpoint:

curl ${APP_URL}/health
# {"status":"healthy"}

View logs in Log Analytics:

az monitor log-analytics query \
  --workspace $(az monitor log-analytics workspace show -g rg-sk-demo -n law-abc123 --query id -o tsv) \
  --analytics-query "ContainerAppConsoleLogs | where ContainerAppName == 'ca-sk-abc123' | project TimeGenerated, Log, ContainerName | order by TimeGenerated desc | limit 20"

GitHub Actions CI/CD pipeline

Create .github/workflows/deploy.yml:

# .github/workflows/deploy.yml
name: Deploy to Azure Container Apps

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

env:
  AZURE_RESOURCE_GROUP: rg-sk-demo
  CONTAINER_APP_NAME: ca-sk-${{ github.run_id }} # overridden by Bicep output
  IMAGE_NAME: sk-azure-deploy

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    outputs:
      acr-login-server: ${{ steps.acr.outputs.login-server }}
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4

      - name: Azure login via OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Get ACR login server from Bicep output
        id: acr
        run: |
          LOGIN_SERVER=$(az deployment group show -g ${{ env.AZURE_RESOURCE_GROUP }} -n main --query properties.outputs.acrLoginServer.value -o tsv)
          echo "login-server=$LOGIN_SERVER" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to ACR
        uses: docker/login-action@v3
        with:
          registry: ${{ steps.acr.outputs.login-server }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ steps.acr.outputs.login-server }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha
            type=ref,event=branch
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Azure login via OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Get Container App name
        id: app
        run: |
          NAME=$(az deployment group show -g ${{ env.AZURE_RESOURCE_GROUP }} -n main --query properties.outputs.containerAppName.value -o tsv)
          echo "name=$NAME" >> $GITHUB_OUTPUT

      - name: Update Container App image
        run: |
          az containerapp update \
            --name ${{ steps.app.outputs.name }} \
            --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \
            --image ${{ needs.build-and-push.outputs.acr-login-server }}/${{ env.IMAGE_NAME }}:latest

Required GitHub repository secrets

Secret Description
AZURE_CLIENT_ID App registration client ID with Contributor on the resource group
AZURE_TENANT_ID Azure AD tenant ID
AZURE_SUBSCRIPTION_ID Subscription ID
ACR_USERNAME ACR admin username (enable admin user temporarily) or use azure/login with acr scope
ACR_PASSWORD ACR admin password or token

For production, replace admin credentials with a federated identity credential on the ACR and grant AcrPush role to the GitHub OIDC identity.

Add managed identity for Key Vault access

The Container App uses a system-assigned managed identity. Grant it Key Vault Secrets User:

IDENTITY=$(az containerapp show -n $CONTAINER_APP -g rg-sk-demo --query identity.principalId -o tsv)
KV_ID=$(az keyvault show -n $KV_NAME --query id -o tsv)

az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $IDENTITY \
  --assignee-principal-type ServicePrincipal \
  --scope $KV_ID

Redeploy the Container App (or push a new image) to pick up the identity permissions.

What you now have

  • Infrastructure as code: Bicep deploys Container Apps Environment, ACR, Key Vault, Log Analytics, and the Container App with secrets wired from Key Vault.
  • Zero-trust secrets: No connection strings or API keys in code, config, or container images. The managed identity pulls secrets at runtime.
  • Autoscaling: HTTP-based scaling from 1 to 10 replicas, configurable via Bicep parameters.
  • CI/CD: GitHub Actions builds a multi-arch image, pushes to ACR, and updates the Container App revision on every merge to main.
  • Observability: Logs flow to Log Analytics; health endpoint enables Azure load balancer probes.

Next steps for production

  1. Custom domain + TLS: Add a custom domain binding and managed certificate to the Container App ingress.
  2. VNet integration: Inject the Container App into a VNet with private endpoints for OpenAI, Key Vault, and ACR.
  3. API Management: Front the Container App with APIM for rate limiting, caching, and developer portal.
  4. Blue/green deployments: Use Container App traffic splitting (traffic array in ingress) for zero-downtime releases.
  5. Distributed tracing: Enable OpenTelemetry in the .NET app and export to Azure Monitor or Grafana.

The pattern scales. Swap the Azure OpenAI connector for any other provider — n4n.ai’s OpenAI-compatible endpoint works the same way, just change the endpoint and key in Key Vault. The infrastructure doesn’t care which model you call.

Tagssemantic-kerneldotnetazuredeployment

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel for .net enterprise apps posts →