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 --versionreturns 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
- Custom domain + TLS: Add a custom domain binding and managed certificate to the Container App ingress.
- VNet integration: Inject the Container App into a VNet with private endpoints for OpenAI, Key Vault, and ACR.
- API Management: Front the Container App with APIM for rate limiting, caching, and developer portal.
- Blue/green deployments: Use Container App traffic splitting (
trafficarray in ingress) for zero-downtime releases. - 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.