Documentation

adrs/020-container-aware-configuration.md

ADR-020: Container-Aware Configuration

Status

Accepted

Context

Dynaplex services need to behave differently depending on their execution environment. Local development, Docker containers, and production Kubernetes deployments have different requirements for configuration, networking, and security. We need a systematic approach to detect the runtime environment and adjust behavior accordingly.

Key differences between environments:

  • CORS requirements (development vs production)
  • Service discovery mechanisms
  • Health check endpoints
  • Configuration sources
  • Security policies
  • Logging verbosity
  • Debug endpoints availability

Decision

We will implement container-aware configuration that detects the runtime environment using the DOTNET_RUNNING_IN_CONTAINER environment variable and other indicators to automatically adjust service behavior.

Pattern:

if (Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true")
{
    // Container-specific configuration
}

Consequences

Positive

  • Automatic Configuration: Services self-configure based on environment
  • Development Experience: Simplified local development setup
  • Security: Different security policies per environment
  • Flexibility: Easy to add environment-specific features
  • Debugging: Enhanced debugging in development
  • Deployment Simplicity: Same image works in multiple environments

Negative

  • Hidden Behavior: Configuration not always explicit
  • Testing Complexity: Must test multiple environment configurations
  • Debugging Issues: Environment-specific bugs harder to reproduce
  • Documentation: Must document environment-specific behaviors

Neutral

  • Environment Detection: Relies on environment variables
  • Configuration Precedence: Environment overrides defaults
  • Runtime Decisions: Some configuration happens at runtime

Implementation Notes

Environment Detection

public static class EnvironmentDetector
{
    public static bool IsRunningInContainer =>
        Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
    
    public static bool IsKubernetes =>
        Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST") != null;
    
    public static bool IsDocker =>
        IsRunningInContainer && !IsKubernetes;
    
    public static bool IsAzureContainerInstance =>
        Environment.GetEnvironmentVariable("ACI_NAME") != null;
    
    public static bool IsDevelopment =>
        Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development";
}

CORS Configuration

// In Program.cs
if (EnvironmentDetector.IsRunningInContainer)
{
    // Enable CORS for NextJS UI in containers
    builder.Services.AddCors(options =>
    {
        options.AddPolicy("ContainerCors", policy =>
        {
            policy.WithOrigins(
                "http://localhost:3000",      // Local NextJS
                "http://assettrak-ui:3000",   // Docker service name
                "https://ui.dynaplex.local"   // Production URL
            )
            .AllowAnyMethod()
            .AllowAnyHeader()
            .AllowCredentials();
        });
    });
    
    // Use the CORS policy
    app.UseCors("ContainerCors");
}
else
{
    // Development: Allow all origins
    if (app.Environment.IsDevelopment())
    {
        builder.Services.AddCors(options =>
        {
            options.AddPolicy("DevCors", policy =>
            {
                policy.AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader();
            });
        });
        
        app.UseCors("DevCors");
    }
}

Service Discovery

public static class ServiceConfiguration
{
    public static void ConfigureServiceDiscovery(this IServiceCollection services, IConfiguration configuration)
    {
        if (EnvironmentDetector.IsKubernetes)
        {
            // Kubernetes: Use DNS service discovery
            services.AddHttpClient("catalog", client =>
            {
                client.BaseAddress = new Uri("http://catalog-service:80");
            });
        }
        else if (EnvironmentDetector.IsDocker)
        {
            // Docker Compose: Use container names
            services.AddHttpClient("catalog", client =>
            {
                client.BaseAddress = new Uri("http://catalog:5000");
            });
        }
        else
        {
            // Local development: Use localhost with ports
            services.AddHttpClient("catalog", client =>
            {
                var port = configuration["Services:Catalog:Port"] ?? "5001";
                client.BaseAddress = new Uri($"http://localhost:{port}");
            });
        }
    }
}

Health Checks

builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy())
    .AddSqlServer(
        connectionString,
        name: "database",
        tags: EnvironmentDetector.IsRunningInContainer 
            ? new[] { "ready", "live" }  // Kubernetes probes
            : new[] { "health" });       // Simple health check

if (EnvironmentDetector.IsKubernetes)
{
    // Kubernetes-specific health endpoints
    app.MapHealthChecks("/health/ready", new HealthCheckOptions
    {
        Predicate = check => check.Tags.Contains("ready")
    });
    
    app.MapHealthChecks("/health/live", new HealthCheckOptions
    {
        Predicate = check => check.Tags.Contains("live")
    });
}
else
{
    // Standard health endpoint
    app.MapHealthChecks("/health");
}

Logging Configuration

builder.Logging.ClearProviders();

if (EnvironmentDetector.IsRunningInContainer)
{
    // Container: JSON logging for structured logs
    builder.Logging.AddJsonConsole(options =>
    {
        options.IncludeScopes = true;
        options.TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
        options.JsonWriterOptions = new JsonWriterOptions
        {
            Indented = false // Compact JSON in containers
        };
    });
    
    // Set appropriate log levels
    builder.Logging.SetMinimumLevel(
        EnvironmentDetector.IsKubernetes 
            ? LogLevel.Information 
            : LogLevel.Debug);
}
else
{
    // Development: Human-readable console logging
    builder.Logging.AddConsole(options =>
    {
        options.IncludeScopes = true;
        options.TimestampFormat = "HH:mm:ss ";
    });
    
    builder.Logging.SetMinimumLevel(LogLevel.Debug);
}

Database Configuration

services.AddDbContext<CatalogDb>(options =>
{
    var connectionString = configuration.GetConnectionString("catalog");
    
    if (EnvironmentDetector.IsRunningInContainer)
    {
        // Container: Use environment variable override if present
        connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") 
            ?? connectionString;
        
        // Add resiliency for container networking
        options.UseSqlServer(connectionString, sqlOptions =>
        {
            sqlOptions.EnableRetryOnFailure(
                maxRetryCount: 5,
                maxRetryDelay: TimeSpan.FromSeconds(30),
                errorNumbersToAdd: null);
        });
    }
    else
    {
        // Local development: Use local connection
        options.UseSqlServer(connectionString);
    }
});

Debug Endpoints

if (!EnvironmentDetector.IsRunningInContainer || EnvironmentDetector.IsDevelopment)
{
    // Development/debug endpoints
    app.MapGet("/debug/config", (IConfiguration config) =>
    {
        return config.AsEnumerable()
            .Where(c => !c.Key.Contains("Password") && !c.Key.Contains("Secret"))
            .ToDictionary(c => c.Key, c => c.Value);
    }).ExcludeFromDescription(); // Don't show in OpenAPI
    
    app.MapGet("/debug/environment", () =>
    {
        return new
        {
            IsContainer = EnvironmentDetector.IsRunningInContainer,
            IsKubernetes = EnvironmentDetector.IsKubernetes,
            IsDocker = EnvironmentDetector.IsDocker,
            Environment = app.Environment.EnvironmentName,
            Variables = Environment.GetEnvironmentVariables()
        };
    }).ExcludeFromDescription();
}

Configuration Sources Priority

var builder = WebApplication.CreateBuilder(args);

// Base configuration
builder.Configuration
    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);

if (EnvironmentDetector.IsRunningInContainer)
{
    // Container: Environment variables take precedence
    builder.Configuration
        .AddJsonFile("/config/appsettings.json", optional: true) // Mounted config
        .AddEnvironmentVariables("DYNAPLEX_");                   // Prefixed env vars
}
else
{
    // Development: User secrets and local settings
    builder.Configuration
        .AddUserSecrets<Program>()
        .AddJsonFile("appsettings.Local.json", optional: true);
}

// Command line args always win
builder.Configuration.AddCommandLine(args);

Dockerfile ENV Variables

FROM mcr.microsoft.com/dotnet/aspnet:9.0
ENV DOTNET_RUNNING_IN_CONTAINER=true
ENV ASPNETCORE_URLS=http://+:80
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 80

Best Practices

  1. Always check environment before applying environment-specific config
  2. Document environment variables required for each environment
  3. Provide sensible defaults that work without configuration
  4. Log environment detection at startup for debugging
  5. Test all environments in CI/CD pipeline
  6. Use configuration hierarchy with clear precedence rules
  7. Avoid production secrets in environment detection logic
  • ADR-007: Migration to .NET Aspire Microservices (container deployment)
  • ADR-019: OpenTelemetry Integration (environment-specific telemetry)
  • ADR-022: Scalar API Documentation (environment-specific API docs)