Learnixo

Serverless with Azure Functions · Lesson 4 of 5

Deploying Azure Functions — GitHub Actions CI/CD

Hosting Plan Comparison

Consumption Plan:
  → Pay per execution (first 1M free/month)
  → Auto-scale to 0 — no cost when idle
  → Cold start: 1-10 seconds on first invocation after idle
  → Max timeout: 10 minutes (configurable to 60 with host.json)
  → Not suitable for: Timer triggers with strict timing, WebSocket connections

Premium Plan:
  → Pre-warmed instances — no cold start
  → Longer max timeout (unlimited with Dedicated plan)
  → VNet integration — access to private resources (SQL, Service Bus)
  → More expensive: pay per instance hour, not per execution
  → Use when: cold start is unacceptable, VNet integration required

Dedicated (App Service) Plan:
  → Run on your existing App Service plan
  → No cold start (always warm)
  → Cost included in App Service plan
  → Use when: you already pay for App Service and want to co-host functions

Clinical recommendation:
  → HTTP webhooks on Consumption (low volume, cold start acceptable for callbacks)
  → Timer triggers on Premium (no missed runs, no cold start delays)
  → Service Bus consumers on Consumption (auto-scale to demand, cost-effective)

GitHub Actions Deployment

YAML
# .github/workflows/deploy-functions.yml
name: Deploy Azure Functions

on:
  push:
    branches: [main]
    paths:
      - 'src/Functions/**'
      - '.github/workflows/deploy-functions.yml'

env:
  DOTNET_VERSION: '8.x'
  FUNCTION_APP_NAME: 'clinical-functions'
  AZURE_RESOURCE_GROUP: 'clinical-rg'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Build and publish Functions
        run: |
          dotnet publish \
            src/Functions/ClinicalFunctions/ClinicalFunctions.csproj \
            --configuration Release \
            --output ./functions-publish

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

      - name: Deploy to staging slot
        uses: azure/functions-action@v1
        with:
          app-name:    ${{ env.FUNCTION_APP_NAME }}
          slot-name:   staging
          package:     './functions-publish'
          respect-pom-xml: false

      - name: Run smoke tests against staging
        run: |
          curl -f "https://${{ env.FUNCTION_APP_NAME }}-staging.azurewebsites.net/api/health" \
            -H "x-functions-key: ${{ secrets.FUNCTIONS_STAGING_KEY }}"

      - name: Swap staging to production
        uses: azure/cli@v2
        with:
          inlineScript: |
            az functionapp deployment slot swap \
              --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \
              --name ${{ env.FUNCTION_APP_NAME }} \
              --slot staging \
              --target-slot production

host.json Configuration

JSON
{
  "version": "2.0",
  "functionTimeout": "00:10:00",
  "logging": {
    "applicationInsights": {
      "samplingSettings": {
        "isEnabled": true,
        "maxTelemetryItemsPerSecond": 20
      }
    }
  },
  "extensions": {
    "serviceBus": {
      "prefetchCount": 5,
      "messageHandlerOptions": {
        "maxConcurrentCalls": 4,
        "autoComplete": false
      }
    }
  },
  "concurrency": {
    "dynamicConcurrencyEnabled": true,
    "snapshotPersistenceEnabled": true
  }
}

local.settings.json for Development

JSON
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "ServiceBus": "Endpoint=sb://clinical-servicebus.servicebus.windows.net/;...",
    "ConnectionStrings__Clinical": "Server=localhost;Database=ClinicalDev;...",
    "KeyVaultUri": "https://clinical-kv.vault.azure.net/"
  }
}
IMPORTANT:
  → local.settings.json is NOT deployed to Azure (it's listed in .gitignore)
  → All settings in local.settings.json["Values"] become environment variables
  → In Azure, set these via Function App → Configuration → Application Settings
  → For secrets: use Key Vault references in Application Settings
    Value: @Microsoft.KeyVault(SecretUri=https://clinical-kv.vault.azure.net/secrets/ServiceBus/...)

Multi-Environment Configuration

C#
// In Program.cs — environment-aware configuration
var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureAppConfiguration((context, config) =>
    {
        config.AddEnvironmentVariables();

        // Load Key Vault in non-development environments
        var keyVaultUri = Environment.GetEnvironmentVariable("KeyVaultUri");
        if (!string.IsNullOrEmpty(keyVaultUri))
        {
            config.AddAzureKeyVault(
                new Uri(keyVaultUri),
                new DefaultAzureCredential());
        }

        // Local development: use Azurite for storage
        if (context.HostingEnvironment.IsDevelopment())
        {
            Environment.SetEnvironmentVariable(
                "AzureWebJobsStorage", "UseDevelopmentStorage=true");
        }
    })
    // ...
    .Build();

Deployment Validation

C#
// Health check endpoint for smoke tests after deployment
[Function("Health")]
public HttpResponseData Run(
    [HttpTrigger(AuthorizationLevel.Function, "get", Route = "health")]
    HttpRequestData req)
{
    var response = req.CreateResponse(HttpStatusCode.OK);
    response.WriteAsJsonAsync(new
    {
        Status  = "Healthy",
        Version = Assembly.GetExecutingAssembly()
                    .GetName().Version?.ToString(),
        Timestamp = DateTime.UtcNow
    });
    return response;
}

Production issue I've seen: A team deployed Azure Functions on a Consumption plan and had Timer Triggers running nightly prescription expiry jobs. The function app had not run for 14 hours (no HTTP traffic), so it was fully deallocated. The Timer Trigger at 2:00 AM failed to start for 7 minutes (cold start on Consumption plan with a large dependency graph). By the time it started, the timer had been delayed past the expected window. The expired prescription cleanup ran 7 minutes late — acceptable for most scenarios, but it missed 3 prescriptions that had a hard 48-hour cutoff based on the timer's expected firing time. Moving the Timer Trigger to a Premium plan with pre-warmed instances resolved cold start delays.


Key Takeaway

Choose the hosting plan based on workload: Consumption for bursty workloads with cold start tolerance, Premium for always-on or VNet-connected functions, Dedicated for co-hosting with existing App Service plans. Use deployment slots (staging → production swap) for zero-downtime releases. Never commit local.settings.json — it's for local development only. In Azure, set Application Settings directly or use Key Vault references. Always include a health check function endpoint for post-deployment smoke tests.