Skip to main content
The FullStackHero .NET Starter Kit follows ASP.NET Core’s hierarchical configuration system. This guide covers configuration best practices, secrets management, and environment-specific overrides.

Configuration Hierarchy

ASP.NET Core loads configuration in the following order (later sources override earlier ones):
1

appsettings.json

Base configuration - Shared across all environments, committed to source control.Contains non-sensitive defaults like:
  • Logging levels
  • OpenTelemetry configuration structure
  • Module settings
  • Feature flags
2

appsettings.{Environment}.json

Environment-specific overrides - Loaded based on ASPNETCORE_ENVIRONMENT.
  • appsettings.Development.json - Dev overrides (CORS, OpenAPI enabled)
  • appsettings.Production.json - Prod overrides (security hardening)
Production file should have empty strings for secrets, never actual values.
3

Environment Variables

Runtime configuration - Highest priority, overrides JSON files.Use for:
  • Secrets (connection strings, API keys, JWT keys)
  • Environment-specific URLs
  • Cloud provider configurations
Supports double-underscore (__) notation:
DatabaseOptions__ConnectionString="Host=..."
4

Secrets Manager (AWS/Azure)

Cloud-native secrets - Injected as environment variables by ECS/Kubernetes.AWS: Secrets Manager → ECS Task Definition → Environment VariablesAzure: Key Vault → Container Apps → Environment Variables
Configuration Priority: Secrets Manager (via env vars) > Environment Variables > appsettings.{Environment}.json > appsettings.json

Configuration Files

appsettings.json (Base)

Contains shared configuration and non-sensitive defaults:
{
  "OpenTelemetryOptions": {
    "Enabled": true,
    "Tracing": { "Enabled": true },
    "Metrics": {
      "Enabled": true,
      "MeterNames": [
        "FSH.Modules.Identity",
        "FSH.Modules.Multitenancy",
        "FSH.Modules.Auditing"
      ]
    },
    "Exporter": {
      "Otlp": {
        "Enabled": true,
        "Endpoint": "http://localhost:4317",
        "Protocol": "grpc"
      }
    }
  },
  "Serilog": {
    "MinimumLevel": { "Default": "Debug" },
    "WriteTo": [
      { "Name": "Console" },
      { "Name": "OpenTelemetry" }
    ]
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Hangfire": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning"
    }
  },
  "DatabaseOptions": {
    "Provider": "POSTGRESQL",
    "ConnectionString": "Server=localhost;Database=fsh;User Id=postgres;Password=password",
    "MigrationsAssembly": "FSH.Playground.Migrations.PostgreSQL"
  },
  "OriginOptions": {
    "OriginUrl": "https://localhost:7030"
  },
  "CachingOptions": {
    "Redis": ""
  },
  "HangfireOptions": {
    "Username": "admin",
    "Password": "Secure1234!Me",
    "Route": "/jobs"
  },
  "PasswordPolicy": {
    "PasswordHistoryCount": 5,
    "PasswordExpiryDays": 90,
    "PasswordExpiryWarningDays": 14,
    "EnforcePasswordExpiry": true
  },
  "AllowedHosts": "*",
  "OpenApiOptions": {
    "Enabled": true,
    "Title": "FSH PlayGround API",
    "Version": "v1",
    "Description": "The FSH Starter Kit API for Modular/Multitenant Architecture."
  },
  "CorsOptions": {
    "AllowAll": false,
    "AllowedOrigins": [
      "https://localhost:4200",
      "https://localhost:7140"
    ]
  },
  "JwtOptions": {
    "Issuer": "fsh.local",
    "Audience": "fsh.clients",
    "SigningKey": "replace-with-256-bit-secret-min-32-chars",
    "AccessTokenMinutes": 2,
    "RefreshTokenDays": 7
  },
  "RateLimitingOptions": {
    "Enabled": false,
    "Global": {
      "PermitLimit": 100,
      "WindowSeconds": 60
    },
    "Auth": {
      "PermitLimit": 10,
      "WindowSeconds": 60
    }
  },
  "MultitenancyOptions": {
    "RunTenantMigrationsOnStartup": true
  },
  "Storage": {
    "Provider": "local"
  }
}
Never commit secrets to appsettings.Production.json. Use empty strings and inject via environment variables or Secrets Manager.

Environment Variables

Naming Convention

ASP.NET Core uses double-underscore (__) to represent nested configuration:
# JSON configuration
{
  "DatabaseOptions": {
    "ConnectionString": "..."
  }
}

# Equivalent environment variable
DatabaseOptions__ConnectionString="Host=..."

Common Configuration Variables

# PostgreSQL connection
DatabaseOptions__Provider=POSTGRESQL
DatabaseOptions__ConnectionString="Host=postgres;Port=5432;Database=fsh;Username=postgres;Password=SecurePassword123;Pooling=true;SSL Mode=Require;Trust Server Certificate=true"
DatabaseOptions__MigrationsAssembly=FSH.Playground.Migrations.PostgreSQL

# SQL Server connection (alternative)
# DatabaseOptions__Provider=SQLSERVER
# DatabaseOptions__ConnectionString="Server=sql;Database=fsh;User Id=sa;Password=SecurePassword123;TrustServerCertificate=True"
# DatabaseOptions__MigrationsAssembly=FSH.Playground.Migrations.MSSQL
AWS RDS: Connection string with SSL Mode=RequireAzure SQL: Connection string with TrustServerCertificate or valid certificate

Setting Environment Variables

# docker-compose.yml
services:
  api:
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - DatabaseOptions__ConnectionString=Host=postgres;...
      - CachingOptions__Redis=redis:6379,password=SecurePassword123
      - JwtOptions__SigningKey=${JWT_SIGNING_KEY}

Secrets Management

AWS Secrets Manager

The Terraform configuration automatically manages secrets:
1

RDS Password (AWS-Managed)

When db_manage_master_user_password = true, AWS generates and stores the password:
terraform/apps/playground/app_stack/main.tf
resource "aws_db_instance" "this" {
  manage_master_user_password = true  # AWS generates password
}

# Read the password and create connection string
data "aws_secretsmanager_secret_version" "rds_password" {
  secret_id = module.rds.secret_arn
}

resource "aws_secretsmanager_secret" "db_connection_string" {
  name = "dev-db-connection-string"
}

resource "aws_secretsmanager_secret_version" "db_connection_string" {
  secret_id = aws_secretsmanager_secret.db_connection_string.id
  secret_string = "Host=${module.rds.endpoint};Database=fsh;Username=postgres;Password=${jsondecode(data.aws_secretsmanager_secret_version.rds_password.secret_string)["password"]};Pooling=true;SSL Mode=Require"
}
The full connection string is injected into ECS tasks via the secrets configuration.
2

JWT Signing Key

Create a secret for the JWT signing key:
# Generate a strong key (32+ characters)
SECRET_KEY=$(openssl rand -base64 48)

# Store in Secrets Manager
aws secretsmanager create-secret \
  --name dev-jwt-signing-key \
  --secret-string "$SECRET_KEY" \
  --region us-east-1

# Get the ARN
aws secretsmanager describe-secret \
  --secret-id dev-jwt-signing-key \
  --query ARN \
  --output text
Update Terraform to inject into ECS:
secrets = [
  {
    name      = "JwtOptions__SigningKey"
    valueFrom = "arn:aws:secretsmanager:us-east-1:123456789012:secret:dev-jwt-signing-key"
  }
]
3

Hangfire Credentials

Store Hangfire dashboard credentials:
aws secretsmanager create-secret \
  --name dev-hangfire-credentials \
  --secret-string '{"username":"admin","password":"SecurePassword123"}' \
  --region us-east-1
Inject into ECS:
secrets = [
  {
    name      = "HangfireOptions__Password"
    valueFrom = "arn:aws:secretsmanager:us-east-1:123456789012:secret:dev-hangfire-credentials:password::"
  }
]
4

Retrieve Secrets

View secrets (for debugging):
# List secrets
aws secretsmanager list-secrets --region us-east-1

# Get secret value
aws secretsmanager get-secret-value \
  --secret-id dev-db-connection-string \
  --query SecretString \
  --output text

Azure Key Vault

For Azure deployments:
# Create Key Vault
az keyvault create \
  --name fsh-dev-keyvault \
  --resource-group fsh-dev-rg \
  --location eastus

# Store secrets
az keyvault secret set \
  --vault-name fsh-dev-keyvault \
  --name database-connection-string \
  --value "Server=...;Database=fsh;..."

az keyvault secret set \
  --vault-name fsh-dev-keyvault \
  --name jwt-signing-key \
  --value "your-secret-key-min-32-chars"

# Grant access to Container App managed identity
az keyvault set-policy \
  --name fsh-dev-keyvault \
  --object-id <managed-identity-id> \
  --secret-permissions get list

Configuration Best Practices

DO:
  • Use appsettings.Production.json with empty strings for secrets
  • Store secrets in AWS Secrets Manager, Azure Key Vault, or environment variables
  • Add .env files to .gitignore
DON’T:
  • Commit passwords, API keys, or connection strings to source control
  • Use placeholder values like password or secret in production config
  • Store secrets in Docker images
Create environment-specific configurations:
appsettings.json              # Base
appsettings.Development.json  # Dev overrides
appsettings.Staging.json      # Staging overrides
appsettings.Production.json   # Prod overrides
Set ASPNETCORE_ENVIRONMENT to load the correct file:
ASPNETCORE_ENVIRONMENT=Production
Add validation to catch configuration errors early:
// In Program.cs or configuration extension
builder.Services.AddOptions<DatabaseOptions>()
    .Bind(builder.Configuration.GetSection("DatabaseOptions"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// DatabaseOptions.cs
public class DatabaseOptions
{
    [Required]
    public string Provider { get; set; } = null!;
    
    [Required]
    [MinLength(10)]
    public string ConnectionString { get; set; } = null!;
}
Build connection strings programmatically for better control:
var builder = new NpgsqlConnectionStringBuilder
{
    Host = configuration["Database__Host"],
    Port = int.Parse(configuration["Database__Port"] ?? "5432"),
    Database = configuration["Database__Name"],
    Username = configuration["Database__Username"],
    Password = configuration["Database__Password"],
    Pooling = true,
    SslMode = SslMode.Require,
    TrustServerCertificate = false
};

var connectionString = builder.ToString();
Maintain a configuration reference:
# Required Configuration

## Database
- `DatabaseOptions__Provider`: `POSTGRESQL` or `SQLSERVER`
- `DatabaseOptions__ConnectionString`: Full connection string with credentials
- `DatabaseOptions__MigrationsAssembly`: Migration project name

## Authentication
- `JwtOptions__SigningKey`: Min 32 characters, store in Secrets Manager
- `JwtOptions__Issuer`: Your domain or app identifier
- `JwtOptions__Audience`: Client identifier

## Storage
- `Storage__Provider`: `local`, `s3`, or `azure`
- `Storage__S3__Bucket`: S3 bucket name (if using S3)
- `Storage__S3__Region`: AWS region

Production Configuration Checklist

Before deploying to production:
1

Security

ASPNETCORE_ENVIRONMENT=Production set✅ All secrets stored in Secrets Manager or Key Vault✅ JWT signing key is strong (min 32 characters) and never committed✅ Database connection uses SSL/TLS (SSL Mode=Require)✅ Redis connection uses SSL if available✅ CORS configured with explicit allowed origins (no AllowAll)AllowedHosts set to your domain
2

Features

✅ OpenAPI/Swagger disabled (OpenApiOptions__Enabled=false)✅ Rate limiting enabled (RateLimitingOptions__Enabled=true)✅ Tenant migrations disabled on startup (MultitenancyOptions__RunTenantMigrationsOnStartup=false)✅ Storage provider set to cloud (s3 or azure, not local)
3

Observability

✅ OpenTelemetry configured with production collector endpoint✅ Serilog minimum level set to Information (not Debug)✅ CloudWatch Logs or equivalent configured✅ Health checks configured (/health/live, /health/ready)
4

Performance

✅ Redis caching configured and tested✅ Database connection pooling enabled✅ S3/Azure storage with CDN (CloudFront/Azure CDN)✅ ECS auto-scaling policies configured
5

Testing

✅ Configuration loaded successfully (check startup logs)✅ Database migrations applied✅ Authentication flow works (login, token refresh)✅ File uploads work (S3/Azure storage)✅ Background jobs run (Hangfire)

Troubleshooting

Problem: Environment variable or secret not being read.Debug:
// Add to Program.cs for debugging
var config = builder.Configuration.GetSection("DatabaseOptions").Get<DatabaseOptions>();
Console.WriteLine($"ConnectionString: {config?.ConnectionString}");
Common issues:
  • Typo in environment variable name (check double underscores)
  • Wrong ASPNETCORE_ENVIRONMENT value
  • Secrets Manager permission denied (check IAM role)
Error: User: arn:aws:sts::123456789012:assumed-role/... is not authorized to perform: secretsmanager:GetSecretValueSolution: Grant ECS task execution role access to secrets:
resource "aws_iam_role_policy" "task_execution_secrets" {
  name = "task-execution-secrets-access"
  role = aws_iam_role.task_execution.id
  
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = ["secretsmanager:GetSecretValue"]
      Resource = [
        aws_secretsmanager_secret.db_connection_string.arn,
        aws_secretsmanager_secret.jwt_signing_key.arn
      ]
    }]
  })
}
Error: Keyword not supported: 'server'Cause: Using SQL Server connection string format with PostgreSQL (or vice versa).Solution:
  • PostgreSQL: Host=...;Port=5432;Database=...;Username=...;Password=...
  • SQL Server: Server=...;Database=...;User Id=...;Password=...
Problem: CorsOptions__AllowedOrigins__0 not loading.Solution: Use index notation for arrays:
CorsOptions__AllowedOrigins__0="https://app.example.com"
CorsOptions__AllowedOrigins__1="https://admin.example.com"
Or use JSON in environment variable:
CorsOptions__AllowedOrigins='["https://app.example.com","https://admin.example.com"]'

Next Steps

AWS Deployment

Learn how Terraform automatically configures secrets and environment variables

Docker Deployment

Set up environment variables and secrets for Docker Compose deployments

Deployment Overview

Review production considerations and deployment best practices

Security Best Practices

Deep dive into authentication, authorization, and security hardening

Build docs developers (and LLMs) love