Best Practices for managing application and environment-specific variables for .NET 9.0 C# 12 Azure
Core Principles:
Configuration Providers: Leverage the standard .NET configuration system (
Microsoft.Extensions.Configuration).Hierarchy of Configuration: Understand that settings can be overridden (e.g., environment variables override
appsettings.json).Secrets Management: Use Azure Key Vault for all secrets in Azure. For local development, use User Secrets or
local.settings.json(with caution for secrets).Environment Consistency: Aim to use the same configuration mechanisms locally and in Azure as much as possible.
Infrastructure as Code (IaC) & DevOps: Manage Azure resources and deployments through pipelines.
Configuration Sources Order (Typical for Isolated Worker):
appsettings.json(shared defaults)appsettings.{EnvironmentName}.json(e.g.,appsettings.Development.json)User Secrets (local development, overrides
appsettings.*.json)local.settings.json(specifically for Azure Functions local development, itsValuesmap to environment variables)Environment Variables (OS level, or Azure App Settings in the cloud - these override all previous file-based settings)
Azure Key Vault (via Key Vault references in App Settings, effectively acting as secure environment variables)
Command-line arguments (less common for Functions).
Step-by-Step Guide:
Phase 1: Project Setup & Local Development
Create Azure Function Project (Isolated Worker):
Use Visual Studio or the .NET CLI:
dotnet new func --isolated-worker --target-framework net9.0 -n MyFunctionApp cd MyFunctionApp
Install Necessary NuGet Packages: The isolated worker SDK usually brings in
Microsoft.Extensions.Configurationbasics. If you need more specific providers:dotnet add package Microsoft.Extensions.Configuration.Json dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions dotnet add package Microsoft.Extensions.DependencyInjection # Usually already there # For User Secrets (local dev) dotnet add package Microsoft.Extensions.Configuration.UserSecretsConfigure
Program.csfor Rich Configuration: Modify yourProgram.csto build a configuration that includesappsettings.json, environment-specificappsettings.{Environment}.json, User Secrets (for local dev), and environment variables.// Program.cs using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System.IO; // Required for Path.GetDirectoryName and Assembly var host = new HostBuilder() .ConfigureAppConfiguration((hostingContext, config) => { var env = hostingContext.HostingEnvironment; // IHostEnvironment var appAssembly = System.Reflection.Assembly.GetExecutingAssembly().Location; config.SetBasePath(Path.GetDirectoryName(appAssembly)) // Crucial for Functions .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true); if (env.IsDevelopment()) { // For local development, User Secrets are a good practice for sensitive data // not suitable for local.settings.json if it's ever committed by mistake. config.AddUserSecrets<Program>(optional: true); } // local.settings.json values are automatically loaded as environment variables // by the Azure Functions Host when running locally. // Environment variables (including those from local.settings.json and Azure App Settings) // are added by default by ConfigureFunctionsWorkerDefaults or ConfigureFunctionsWebApplication // and will override appsettings files. // You can explicitly add them too if needed: config.AddEnvironmentVariables(); }) .ConfigureFunctionsWorkerDefaults() // Or .ConfigureFunctionsWebApplication() if using ASP.NET Core integration .ConfigureServices((hostContext, services) => { // Get configuration instance IConfiguration configuration = hostContext.Configuration; // Option 1: Register strongly-typed settings (Best Practice) services.Configure<MyApplicationSettings>(configuration.GetSection("MyApplication")); services.Configure<MyEnvironmentSpecificSettings>(configuration.GetSection("EnvironmentSpecific")); // Option 2: Register IConfiguration directly if needed (less type-safe) // services.AddSingleton(configuration); // Register your services services.AddSingleton<IMyService, MyService>(); // ... other services }) .Build(); host.Run(); // Define your settings classes public class MyApplicationSettings { public string? ApiKey { get; set; } public string? ServiceUrl { get; set; } public int DefaultTimeoutSeconds { get; set; } } public class MyEnvironmentSpecificSettings { public string? DatabaseConnectionString { get; set; } public string? StorageAccountName { get; set; } }Note on
SetBasePath: For Azure Functions, especially when deployed, setting the base path explicitly to the assembly's directory ensuresappsettings.jsonfiles are found correctly.Directory.GetCurrentDirectory()can be unreliable in the Azure Functions runtime environment.
Create
appsettings.json: This file contains default or shared settings.// appsettings.json { "MyApplication": { "ServiceUrl": "https://default.api.example.com", "DefaultTimeoutSeconds": 30 }, "EnvironmentSpecific": { "StorageAccountName": "commondatastorage" }, "Logging": { // Example, can be configured here too "LogLevel": { "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } } }Ensure "Copy to Output Directory" is set to "Copy if newer" or "Copy always" for these JSON files in their properties in Visual Studio, or via the
.csprojfile:<ItemGroup> <None Update="appsettings.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="appsettings.Development.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <DependentUpon>appsettings.json</DependentUpon> </None> <None Update="appsettings.Production.json"> <!-- Or UAT, Test etc. --> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <DependentUpon>appsettings.json</DependentUpon> </None> </ItemGroup>Create
appsettings.Development.json: This overrides settings for the "Development" environment.// appsettings.Development.json { "MyApplication": { "ServiceUrl": "https://dev.api.example.com" }, "EnvironmentSpecific": { "DatabaseConnectionString": "DevelopmentDB_ConnectionString", // Still better in User Secrets or local.settings.json "StorageAccountName": "devdatastorage" } }Set up User Secrets (for sensitive local dev data):
Right-click the project in Visual Studio -> "Manage User Secrets". Or via CLI:
dotnet user-secrets init dotnet user-secrets set "MyApplication:ApiKey" "MY_LOCAL_DEV_API_KEY_SECRET" dotnet user-secrets set "EnvironmentSpecific:DatabaseConnectionString" "local_dev_db_connection_string_from_user_secrets"This creates a
secrets.jsonfile outside your project directory, specific to your user profile.
Understand
local.settings.json: This file is primarily for the Azure Functions Core Tools when running locally.IsEncrypted: Set tofalsefor local dev.Values: These key-value pairs are loaded as environment variables for your local function host. They will override settings fromappsettings.jsonandappsettings.Development.json.Host: Settings for the Functions host itself (e.g.,CORS,CORSCredentials).ConnectionStrings: Can be used for connection strings.
// local.settings.json { "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", // For localAzurite "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", "AZURE_FUNCTIONS_ENVIRONMENT": "Development", // This sets IHostEnvironment.EnvironmentName // These will override appsettings.*.json and User Secrets if keys match // Because they become environment variables "MyApplication:ApiKey": "LOCAL_SETTINGS_API_KEY", // Example: if you prefer it here over User Secrets "EnvironmentSpecific:DatabaseConnectionString": "Server=(localdb)\\mssqllocaldb;Database=MyLocalDevDb;Trusted_Connection=True;" // "MyKeyVaultUri": "https://my-dev-kv.vault.azure.net/" // For local Key Vault access if needed }, "ConnectionStrings": { // Alternative way to define connection strings // "MyDbConnection": "local_db_connection_string_from_local_settings" } }IMPORTANT: Add
local.settings.jsonto your.gitignorefile if it contains any real secrets. It's common to commit alocal.settings.json.templateorlocal.settings.sample.jsonwith placeholder values.Accessing Configuration in your Function: Use Dependency Injection and the Options pattern.
// MyHttpFunction.cs using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Net; public class MyHttpFunction { private readonly ILogger<MyHttpFunction> _logger; private readonly MyApplicationSettings _appSettings; private readonly MyEnvironmentSpecificSettings _envSettings; private readonly IMyService _myService; // Inject IOptions<T> public MyHttpFunction( ILogger<MyHttpFunction> logger, IOptions<MyApplicationSettings> appSettings, IOptions<MyEnvironmentSpecificSettings> envSettings, IMyService myService) { _logger = logger; _appSettings = appSettings.Value; // Get the actual settings object _envSettings = envSettings.Value; _myService = myService; } [Function("MyHttpTrigger")] public async Task<HttpResponseData> Run( [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req) { _logger.LogInformation("C# HTTP trigger function processed a request."); _logger.LogInformation("ServiceUrl from config: {ServiceUrl}", _appSettings.ServiceUrl); _logger.LogInformation("DefaultTimeoutSeconds from config: {Timeout}", _appSettings.DefaultTimeoutSeconds); _logger.LogInformation("ApiKey from config: {ApiKey}", _appSettings.ApiKey); // Will be null if not set anywhere _logger.LogInformation("DatabaseConnectionString from config: {DbConn}", _envSettings.DatabaseConnectionString); _logger.LogInformation("StorageAccountName from config: {StorageName}", _envSettings.StorageAccountName); var message = await _myService.DoSomethingAsync(); var response = req.CreateResponse(HttpStatusCode.OK); response.Headers.Add("Content-Type", "text/plain; charset=utf-8"); await response.WriteStringAsync($"Welcome to Azure Functions! Message: {message}"); return response; } } public interface IMyService { Task<string> DoSomethingAsync(); } public class MyService : IMyService { private readonly string _apiKey; public MyService(IOptions<MyApplicationSettings> appSettings) { // Example of accessing specific value if needed directly in a service _apiKey = appSettings.Value.ApiKey ?? throw new ArgumentNullException("ApiKey is missing"); } public Task<string> DoSomethingAsync() => Task.FromResult($"Service used API key starting with: {_apiKey.Substring(0, Math.Min(5, _apiKey.Length))}"); }Running Locally:
Set
AZURE_FUNCTIONS_ENVIRONMENTinlocal.settings.jsonto "Development" (or "Test", "UAT" if you want to test otherappsettings.{env}.jsonfiles locally).The Functions Host will read
local.settings.jsonand make itsValuesavailable as environment variables.Your
Program.csconfiguration setup will then correctly loadappsettings.json,appsettings.Development.json, User Secrets, and then these environment variables (fromlocal.settings.json) will take final precedence for any overlapping keys.
Phase 2: Azure Deployment & Configuration
Azure Key Vault Setup:
For each environment (dev, test, uat, prod), create a separate Azure Key Vault instance (e.g.,
myfunc-dev-kv,myfunc-test-kv,myfunc-prod-kv).Store all secrets (API keys, connection strings, etc.) in these Key Vaults.
Example Secret Name in Key Vault:
MyApplication--ApiKey(use double underscore--for section nesting, as it translates to:in configuration).Another:
EnvironmentSpecific--DatabaseConnectionString
Azure Function App Configuration (App Settings): When you deploy your Function App to Azure, you configure it using "Application Settings" in the Azure portal (or via ARM/Bicep/Terraform).
AZURE_FUNCTIONS_ENVIRONMENT: Set this App Setting toDevelopment,Test,UAT, orProductionfor the respective Function App instance. This controls whichappsettings.{EnvironmentName}.jsonfile is loaded if present, and also theenv.IsDevelopment(),env.IsProduction()checks inProgram.cs.Non-Sensitive Settings: Can be set directly as App Settings.
MyApplication:ServiceUrl=https://prod.api.example.comMyApplication:DefaultTimeoutSeconds=60
Sensitive Settings (Key Vault References): This is the best practice.
Grant your Function App's Managed Identity (System-Assigned or User-Assigned)
Get(and sometimesList) permissions on secrets in the corresponding Key Vault.In the Function App's Application Settings, use Key Vault reference syntax:
Name:
MyApplication:ApiKeyValue:
@Microsoft.KeyVault(SecretUri=https://myfunc-prod-kv.vault.azure.net/secrets/MyApplication--ApiKey/YOUR_SECRET_VERSION_GUID)Or, for latest version:
@Microsoft.KeyVault(VaultName=myfunc-prod-kv;SecretName=MyApplication--ApiKey)Similarly for
EnvironmentSpecific:DatabaseConnectionString.
These App Settings (sourced from Key Vault) become environment variables in the Function App's runtime, overriding any values from
appsettings.*.jsonfiles.
.gitignore: Ensure these are in your.gitignore:# Local settings local.settings.json # User Secrets **/secrets.json # Binaries and build artifacts [Bb]in/ [Oo]bj/You should commit
appsettings.jsonand potentiallyappsettings.Development.json(if it contains no secrets),appsettings.Production.jsonetc., if they define structural or default non-sensitive configurations. Secrets always go into Key Vault for Azure environments.
Phase 3: Azure DevOps YAML Pipelines
Here's how to manage environment-specific configurations in Azure DevOps:
Variable Groups:
Create Variable Groups for each environment (e.g.,
MyFunctionApp-Dev-Vars,MyFunctionApp-Test-Vars,MyFunctionApp-UAT-Vars,MyFunctionApp-Prod-Vars).Option A (Recommended for Secrets): Link to Azure Key Vault.
In your Variable Group, toggle "Link secrets from an Azure Key Vault as variables".
Select your Azure Subscription and the appropriate Key Vault (e.g.,
myfunc-dev-kvfor the dev variable group).Authorize the connection.
Add the specific secrets you want to pull (e.g.,
MyApplication--ApiKey,EnvironmentSpecific--DatabaseConnectionString). These will become pipeline variables.
Option B (For Non-Secrets): Define Variables Directly.
You can define non-sensitive variables directly in the group (e.g.,
serviceUrl,timeout).
YAML Pipeline (
azure-pipelines.yml):trigger: - main # Or your main branch pool: vmImage: 'windows-latest' # Or 'ubuntu-latest' if your tools support it variables: - name: buildConfiguration # For dotnet build/publish value: 'Release' - name: dotnetVersion value: '9.0.x' # Specify your .NET version stages: - stage: Build jobs: - job: BuildJob steps: - task: UseDotNet@2 displayName: 'Use .NET SDK $(dotnetVersion)' inputs: packageType: 'sdk' version: '$(dotnetVersion)' - script: dotnet build --configuration $(buildConfiguration) displayName: 'Build solution' - task: DotNetCoreCLI@2 displayName: 'Publish Function App' inputs: command: 'publish' publishWebProjects: false # Important for Functions projects: '**/*.csproj' # Adjust if needed, point to your Function App csproj arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)/App --runtime win-x64 --self-contained false' # Adjust runtime as needed zipAfterPublish: true - task: PublishBuildArtifacts@1 displayName: 'Publish Artifact: App' inputs: PathtoPublish: '$(Build.ArtifactStagingDirectory)/App' ArtifactName: 'App' publishLocation: 'Container' - stage: DeployDev displayName: 'Deploy to Development' dependsOn: Build condition: succeeded() # Or specific branch conditions variables: - group: MyFunctionApp-Dev-Vars # Link your DEV variable group jobs: - deployment: DeployFunctionAppDev environment: 'MyFunctionApp-Development' # Azure DevOps Environment strategy: runOnce: deploy: steps: - task: AzureFunctionApp@2 # Use version 2 or higher displayName: 'Deploy Azure Function App to Dev' inputs: azureSubscription: 'Your-Azure-Dev-Subscription-Service-Connection' appType: 'functionApp' # For Windows or functionAppLinux for Linux appName: 'my-dev-functionapp-name' # Your Function App name in Azure package: '$(Pipeline.Workspace)/App/**/*.zip' deploymentMethod: 'auto' # Or zipDeploy, runFromPackage appSettings: >- -AZURE_FUNCTIONS_ENVIRONMENT "Development" -MyApplication:ServiceUrl "$(devServiceUrl)" -MyApplication:ApiKey "@Microsoft.KeyVault(SecretUri=$(DevApiKeySecretUri))" -EnvironmentSpecific:DatabaseConnectionString "@Microsoft.KeyVault(SecretUri=$(DevDbConnSecretUri))" # Add other non-secret settings or Key Vault references here from your variable group # Example: -MyApplication:DefaultTimeoutSeconds "$(devDefaultTimeout)" # If using Key Vault references extensively, you might pre-configure them in ARM/Bicep # and only set AZURE_FUNCTIONS_ENVIRONMENT here. - stage: DeployTest displayName: 'Deploy to Test' dependsOn: Build # Or DeployDev if you want sequential deployment condition: succeeded() # And potentially other conditions (e.g., approval, branch) variables: - group: MyFunctionApp-Test-Vars jobs: - deployment: DeployFunctionAppTest environment: 'MyFunctionApp-Test' strategy: runOnce: deploy: steps: - task: AzureFunctionApp@2 displayName: 'Deploy Azure Function App to Test' inputs: azureSubscription: 'Your-Azure-Test-Subscription-Service-Connection' appType: 'functionApp' appName: 'my-test-functionapp-name' package: '$(Pipeline.Workspace)/App/**/*.zip' deploymentMethod: 'auto' appSettings: >- -AZURE_FUNCTIONS_ENVIRONMENT "Test" -MyApplication:ServiceUrl "$(testServiceUrl)" -MyApplication:ApiKey "@Microsoft.KeyVault(SecretUri=$(TestApiKeySecretUri))" -EnvironmentSpecific:DatabaseConnectionString "@Microsoft.KeyVault(SecretUri=$(TestDbConnSecretUri))" # ... Similar stages for UAT and Prod ... - stage: DeployProd displayName: 'Deploy to Production' dependsOn: Build # Or DeployUAT condition: succeeded() # Add manual approval for Prod variables: - group: MyFunctionApp-Prod-Vars jobs: - deployment: DeployFunctionAppProd environment: 'MyFunctionApp-Production' # This DevOps environment should have approvals configured strategy: runOnce: deploy: steps: - task: AzureFunctionApp@2 displayName: 'Deploy Azure Function App to Prod' inputs: azureSubscription: 'Your-Azure-Prod-Subscription-Service-Connection' appType: 'functionApp' appName: 'my-prod-functionapp-name' package: '$(Pipeline.Workspace)/App/**/*.zip' deploymentMethod: 'runFromPackage' # Recommended for prod appSettings: >- -AZURE_FUNCTIONS_ENVIRONMENT "Production" -MyApplication:ServiceUrl "$(prodServiceUrl)" -MyApplication:ApiKey "@Microsoft.KeyVault(SecretUri=$(ProdApiKeySecretUri))" -EnvironmentSpecific:DatabaseConnectionString "@Microsoft.KeyVault(SecretUri=$(ProdDbConnSecretUri))"Explanation of
appSettingsin YAML:The
appSettingsparameter in theAzureFunctionApp@2task allows you to set or override Application Settings in your Azure Function App during deployment.-SettingName "Value": For regular values.-SettingName "@Microsoft.KeyVault(SecretUri=$(PipelineVariableContainingSecretUri))": For Key Vault references. The pipeline variable (e.g.,DevApiKeySecretUri) would be defined in your Variable Group and linked to the Key Vault secret.You can use pipeline variables (e.g.,
$(devServiceUrl)) that are defined in the linked Variable Group.
Azure DevOps Environments & Approvals:
For UAT and Prod, configure "Environments" in Azure DevOps (Pipelines -> Environments).
Add manual approval checks to these environments to ensure a human gate before deploying to critical stages.
Summary of Best Practices:
Consistent Configuration Model: Use
Microsoft.Extensions.Configurationacross local and Azure.Environment Identification:
Locally:
AZURE_FUNCTIONS_ENVIRONMENTinlocal.settings.json.Azure:
AZURE_FUNCTIONS_ENVIRONMENTas an App Setting in the Function App.
Configuration Files:
appsettings.jsonfor defaults.appsettings.{EnvironmentName}.jsonfor environment-specific non-sensitive overrides. Commit these.
Local Development Secrets:
User Secrets (preferred for sensitive items not directly related to Functions host).
local.settings.json(ensure it's in.gitignoreif it contains real secrets). Values here become environment variables locally.
Azure Secrets: Azure Key Vault is non-negotiable. Use Key Vault references in Function App Settings.
Accessing Settings: Use Dependency Injection with
IOptions<T>for strongly-typed configuration.Azure DevOps:
Use Variable Groups, linking to Key Vault for secrets for each environment.
Use stage-specific deployments in your YAML pipeline.
Set
AZURE_FUNCTIONS_ENVIRONMENTand other Key Vault references/app settings via theAzureFunctionApp@2task'sappSettingsparameter.Alternatively, manage App Settings entirely via IaC (Bicep/ARM/Terraform) and only set
AZURE_FUNCTIONS_ENVIRONMENTif it's dynamic per stage. However, Key Vault references often still need to be set on the App resource. The pipeline'sappSettingsallows dynamic updates.
This comprehensive approach provides a robust, secure, and maintainable way to manage configurations for your .NET 9 Azure Functions across all environments.
Resources:
ASP.NET Core Configuration: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/ (Principles apply to isolated worker)
Azure Functions - local.settings.json: https://docs.microsoft.com/en-us/azure/azure-functions/functions-develop-local#local-settings-file
Dependency injection in .NET Azure Functions (isolated worker): https://docs.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide#dependency-injection
Key Vault References for App Service and Azure Functions: https://docs.microsoft.com/en-us/azure/app-service/app-service-key-vault-references
Azure DevOps Variable Groups: https://docs.microsoft.com/en-us/azure/devops/pipelines/library/variable-groups
AzureFunctionApp@2 Task: https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/deploy/azure-function-app
User Secrets in .NET: https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets
Last updated