Skip to main content

Configuration and Environments: appsettings, Secrets, Options – Web API | Lesson 70

Published / updated

Introduction​

An API needs settings: the database connection string, the school's name, the default page size. They differ between your laptop and the real server, and some, like passwords, must never be in source code at all.

Configuration is ASP.NET Core's system for reading settings from several places and combining them. This lesson moves SchoolApp.Api's connection string and school details into configuration, keeps the local LocalDB connection string out of the repository, and checks every setting when the app starts.

By the end of this lesson, you will be able to:

  • Read settings from appsettings.json and environment-specific files.
  • Explain the order in which configuration sources override each other.
  • Keep local secrets out of source control with user secrets.
  • Override settings with environment variables and the command line.
  • Bind a section to an options class, and validate it at startup.

Why this matters​

Leaked connection strings and passwords in public repositories are one of the most common security incidents in the industry. Configuration done right keeps them out of code entirely.

It also makes one build run anywhere. The same compiled API runs on your laptop, a test server, and production, with only its settings changing. That is how real deployments work, and Module 12 relies on it.

The concept​

Example: configuration sources​

WebApplication.CreateBuilder reads settings from these sources, in this order:

OrderSourceTypical use
1appsettings.jsonDefaults shared by every environment, committed to Git
2appsettings.{Environment}.jsonDifferences for one environment, such as Development
3User secrets (Development only)Your own local secrets, outside the repository
4Environment variablesServer settings and secrets set by the host
5Command-line argumentsOne-off overrides when starting the app

A later source overrides an earlier one for the same key. Think of stacked transparent sheets: you see each setting's value from the highest sheet that has it.

Example: keys and sections​

Settings form a tree. In JSON, "School": { "Name": "..." } gives the key School:Name, with : between levels. ConnectionStrings:SchoolDb is read with the shortcut GetConnectionString("SchoolDb").

Environment variables cannot contain : on every operating system, so they use a double underscore: School__Name sets School:Name. Command-line arguments use the colon: --School:Name="Test School".

Example: user secrets​

User secrets store settings in a file in your user profile, not in the project folder, so they never reach Git. On Windows the file is %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json, where the id is the UserSecretsId in the project file.

They are a development convenience, not a vault: the file is plain text, and the app reads it only in Development. Real servers get secrets from environment variables or a secret store such as a key vault (Module 12).

Example: the options pattern​

Reading builder.Configuration["School:Name"] everywhere gives you strings, typos, and no checks. The options pattern binds a whole section to a class, such as SchoolOptions, and injects it.

InterfaceGivesUse for
IOptions<T>One value, read onceSettings that do not change while the app runs
IOptionsSnapshot<T>A fresh value per request (scoped)Settings that may change in a file while running
IOptionsMonitor<T>The current value, plus change notifications (singleton)Singletons that must see changes

With ValidateDataAnnotations() and ValidateOnStart(), the attributes on the class are checked when the app starts, so a wrong setting stops startup instead of breaking the first request that uses it.

Apply it to the SMS​

Step 1: settings in appsettings.json​

src/SchoolApp.Api/appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"SchoolDb": "Server=localhost;Database=SchoolDb;Trusted_Connection=True;TrustServerCertificate=True;"
},
"School": {
"Name": "NexCoding Public School",
"CurrentAcademicYear": "2026-27",
"DefaultPageSize": 20
}
}

The committed connection string points at localhost with Windows sign-in, the course's main setup. It holds no password, so it is safe in Git. The School section holds what GET /api/school returned as fixed values until now.

Step 2: an options class​

src/SchoolApp.Api/Options/SchoolOptions.cs
using System.ComponentModel.DataAnnotations;

namespace SchoolApp.Api.Options;

// Lesson 70: the "School" section of configuration, as a class. The attributes are checked when the
// app starts (ValidateOnStart), so a wrong setting stops it at once instead of failing later.
public class SchoolOptions
{
public const string SectionName = "School";

[Required]
public string Name { get; set; } = "";

[Required]
[RegularExpression(@"^\d{4}-\d{2}$", ErrorMessage = "CurrentAcademicYear must look like 2026-27.")]
public string CurrentAcademicYear { get; set; } = "";

[Range(1, 100)]
public int DefaultPageSize { get; set; } = 20;
}

The attributes come from System.ComponentModel.DataAnnotations, the same ones Lesson 73 uses for request validation. [Range(1, 100)] matches Paging.MaxPageSize from Lesson 48.

Step 3: Program.cs​

src/SchoolApp.Api/Program.cs
// Lesson 66: the start of SchoolApp.Api. Program.cs has two halves:
// 1. Register services (the "builder" part), 2. build the app and describe how requests are handled.
using SchoolApp.Api.Diagnostics;
using SchoolApp.Api.Middleware;
using SchoolApp.Api.Options;
using SchoolApp.Core.Services;
using SchoolApp.Data;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Services: things the app can use. AddControllers finds every controller class in this project.
builder.Services.AddControllers();
builder.Services.AddOpenApi(); // describes the API as an OpenAPI document (Lesson 71)

// Lesson 70: settings come from configuration: appsettings.json, then appsettings.{Environment}.json,
// then user secrets (Development only), then environment variables, then the command line.
// A later source overrides an earlier one.
string? connectionString = builder.Configuration.GetConnectionString("SchoolDb");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("The connection string 'SchoolDb' is missing or empty.");
}

// The "School" section as a SchoolOptions object, checked when the app starts.
builder.Services.AddOptions<SchoolOptions>()
.BindConfiguration(SchoolOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();

// Lesson 69: the dependency injection container. Each line says "when something asks for X, give it Y,
// and keep it for this long". SchoolApp.Data registers its own classes.
builder.Services.AddSchoolData(connectionString);
builder.Services.AddScoped<StudentService>();
builder.Services.AddScoped<FeePaymentService>();

// Lesson 69: the three lifetimes, side by side (see DiagnosticsController).
builder.Services.AddSingleton<SingletonExample>(); // one object for the whole app
builder.Services.AddScoped<ScopedExample>(); // one object per request
builder.Services.AddTransient<TransientExample>(); // a new object every time one is asked for
builder.Services.AddScoped<LifetimeReporter>();

WebApplication app = builder.Build();

// The request pipeline: what happens to every request, in this order (Lesson 67).
// Each Use... adds one middleware; a request goes down the list, and the response comes back up.
app.UseRequestTiming(); // first, so its time includes everything below it

if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json, only while developing
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // send each request to the matching controller action

app.Run(); // start the web server and wait for requests

GetConnectionString("SchoolDb") replaces SqlConnectionFactory.FromEnvironment() in the API. The console program and the tests from Module 9 still use SCHOOLDB_CONNECTION.

The check uses string.IsNullOrWhiteSpace, not just ??, because a setting can exist and be empty. Starting with -- --ConnectionStrings:SchoolDb= proved it: the app stopped at once with "The connection string 'SchoolDb' is missing or empty", instead of failing at the first query.

AddOptions<SchoolOptions>().BindConfiguration("School") connects the class to its section.

Step 4: use the options​

src/SchoolApp.Api/Controllers/SchoolController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SchoolApp.Api.Models;
using SchoolApp.Api.Options;

namespace SchoolApp.Api.Controllers;

// Lesson 66: the first controller. [ApiController] turns on API behaviour (Lesson 73);
// [Route] gives its address; each public method with an HTTP attribute is an action.
// Lesson 70: the school's name and year now come from configuration, through IOptions<SchoolOptions>.
[ApiController]
[Route("api/school")]
public class SchoolController : ControllerBase
{
private readonly SchoolOptions _school;

public SchoolController(IOptions<SchoolOptions> school)
{
_school = school.Value;
}

// GET /api/school
[HttpGet]
public SchoolInfo Get()
{
return new SchoolInfo(_school.Name, _school.CurrentAcademicYear, DateTime.UtcNow);
}
}

The controller asks for IOptions<SchoolOptions> and keeps .Value. It never knows which source each value came from.

Example: IOptions or IOptionsSnapshot​

ASP.NET Core watches appsettings.json while the app runs. An experiment: with the API running, the school's name was changed in the file, and GET /api/school was called again.

With IOptions<SchoolOptions>, the old name came back: the value was read once and kept. With IOptionsSnapshot<SchoolOptions> in the controller instead, the next request returned "Renamed While Running", with no restart.

SchoolApp keeps IOptions: a school's name does not change mid-day, and a value read once cannot change halfway through a request. Use the snapshot only for settings you really need to change without restarting, and remember that most servers restart on deployment anyway.

Step 5: your local connection string, as a user secret​

Terminal
dotnet user-secrets init --project src/SchoolApp.Api
dotnet user-secrets set "ConnectionStrings:SchoolDb" "Server=(localdb)\MSSQLLocalDB;Database=SchoolDb;Trusted_Connection=True;TrustServerCertificate=True;" --project src/SchoolApp.Api
dotnet user-secrets list --project src/SchoolApp.Api

init adds a UserSecretsId to the project file, already committed in the course repository, so you only need set. After that, dotnet run --project src/SchoolApp.Api connects to LocalDB with no environment variable at all. Skip this step if you use a full SQL Server on localhost.

Step 6: watch the overrides​

With no overrides, GET /api/school returns the values from appsettings.json:

Output
{"name":"NexCoding Public School","currentAcademicYear":"2026-27","serverTimeUtc":"2026-10-07T14:51:23.7094985Z"}

Now start it with an environment variable and a command-line argument, in Git Bash:

Terminal
School__CurrentAcademicYear=2027-28 dotnet run --project src/SchoolApp.Api -- --School:Name="Test School"
Output
{"name":"Test School","currentAcademicYear":"2027-28","serverTimeUtc":"2026-10-07T14:51:37.0433307Z"}

Both values changed with no code change. In PowerShell, set the variable first with $env:School__CurrentAcademicYear = "2027-28". The -- separates dotnet run's options from the app's own arguments.

Example: a wrong setting stops startup​

Set the default page size to 500, above the allowed 100:

Terminal
School__DefaultPageSize=500 dotnet run --project src/SchoolApp.Api
Output
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'SchoolOptions' members: 'DefaultPageSize' with the error: 'The field DefaultPageSize must be between 1 and 100.'.

The app stops before accepting a single request, and the message names the setting and the rule. On a real server, a deployment with a wrong setting fails at once, and the previous version keeps running.

Example: a setting for one environment only​

Seeing the SQL that EF Core sends is useful while developing, and noise on a real server. Log levels are settings too, so each environment file can choose:

src/SchoolApp.Api/appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}

appsettings.json sets Microsoft.EntityFrameworkCore to Warning for everyone. appsettings.Development.json raises one category, Microsoft.EntityFrameworkCore.Database.Command, to Information. A more specific category wins over a general one.

Requesting Kabir in Development logged the SQL, with @admissionNumber sent as VARCHAR(15) (AnsiString, Size = 15), as in Lesson 59. The same request in Production logged no SQL at all. One build, two behaviours, chosen by configuration.

Example: what goes where​

SettingWhere
Defaults safe for everyone (page size, school name)appsettings.json
Development-only differences (more logging)appsettings.Development.json
Your local connection string or test passwordsUser secrets
Production connection strings, keys, passwordsEnvironment variables or a secret store on the server
A one-off change for one runCommand-line arguments

Never put a production password in any appsettings file, even in a private repository. Repositories get copied, shared, and leaked more often than anyone plans.

If a secret ever reaches Git, deleting the line is not enough: it stays in the history. Change the password at once, then clean the history if you must. Treat it as leaked from the moment it was pushed.

Common mistakes​

  1. Passwords in appsettings.json. They end up in Git history forever. Fix: user secrets locally, environment variables or a vault on servers.
  2. School:Name as an environment variable on Linux. The colon is not allowed there. Fix: School__Name.
  3. Reading Configuration["..."] all over the code. Typos fail silently with null. Fix: options classes.
  4. No validation. A wrong value breaks the first request that uses it, maybe days later. Fix: ValidateDataAnnotations().ValidateOnStart().
  5. Expecting user secrets on the server. They load only in Development. Fix: configure servers through environment variables or a secret store.

In real projects​

From 16 years of .NET projects

A school group's API once went live with a test database connection string. Every admission that morning went into the test database, and nobody noticed until the principal asked why the new students were missing.

The connection string had been in appsettings.Production.json, copied from a test file by mistake, and nothing checked it.

We moved every connection string out of files into the server's settings, and added a startup check that the database name matched the environment. Configuration mistakes are inevitable; finding them at startup is a choice.

Best practices​

  • Commit safe defaults in appsettings.json; keep secrets out of every committed file.
  • Use user secrets locally and environment variables or a vault in production.
  • Bind each section to an options class, and inject it.
  • Validate options at startup with ValidateOnStart().
  • Fail fast with a clear message when a required setting is missing.

Recap​

  • Configuration combines appsettings.json, the environment file, user secrets, environment variables, and the command line.
  • A later source overrides an earlier one; keys use :, environment variables use __.
  • User secrets keep local secrets outside the repository, in Development only.
  • The options pattern binds a section to a class, injected as IOptions<T>.
  • ValidateOnStart() stopped the API at once when DefaultPageSize was 500.

Homework​

Library module task: Move the library's rules into a settings file, 070-library-settings.json, with a Library section: name, loan days, and fine per day. Bind it to a validated LibraryOptions class, return it from GET /api/library, then override the loan days from the command line. Finally, try a loan of 0 days and see startup fail.

A correct result looks like:

Output
{"name":"NexCoding Public School Library","loanDays":14,"finePerDay":2}
{"name":"NexCoding Public School Library","loanDays":21,"finePerDay":2}
Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'LibraryOptions' members: 'LoanDays' with the error: 'The field LoanDays must be between 1 and 60.'.
Show the solution
library-assignment/070-library-settings.json
{
"Library": {
"Name": "NexCoding Public School Library",
"LoanDays": 14,
"FinePerDay": 2
}
}
library-assignment/070-library-options.cs
// Library homework, Lesson 70: the library's rules come from a settings file, as a validated options
// class, and can be overridden from the command line without changing the file.
// Run with: dotnet run library-assignment/070-library-options.cs
// Override: dotnet run library-assignment/070-library-options.cs -- --Library:LoanDays=21
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// The settings file sits next to this .cs file, in the app's content root. Sources added later win,
// so the command line is added again after it to keep its place at the top.
builder.Configuration.AddJsonFile("070-library-settings.json");
builder.Configuration.AddCommandLine(args);

builder.Services.AddOptions<LibraryOptions>()
.BindConfiguration("Library")
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddControllers();

WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5070");

public class LibraryOptions
{
[Required]
public string Name { get; set; } = "";

[Range(1, 60)]
public int LoanDays { get; set; }

[Range(0, 100)]
public decimal FinePerDay { get; set; }
}

[ApiController]
[Route("api/library")]
public class LibraryController : ControllerBase
{
private readonly LibraryOptions _options;

public LibraryController(IOptions<LibraryOptions> options)
{
_options = options.Value;
}

[HttpGet]
public LibraryOptions Get() => _options;
}

Run it three times: with no arguments, with -- --Library:LoanDays=21, and with -- --Library:LoanDays=0. A single-file app's content root is its own folder, so the settings file is found next to the .cs file.

Quiz​

  1. appsettings.json says Name is A; an environment variable says B. What does the app use?

    1. A
    2. B
    3. Both
    4. It throws
    Show answer

    Answer: B. B Environment variables come later, so they override appsettings.json.

  2. How do you write the key School:Name as an environment variable?

    1. School:Name
    2. School__Name
    3. School.Name
    4. SCHOOL-NAME
    Show answer

    Answer: B. School__Name A double underscore stands for the colon.

  3. Where are user secrets stored?

    1. In appsettings.json
    2. In a file in your user profile, outside the project
    3. In the database
    4. In Git, encrypted
    Show answer

    Answer: B. In a file in your user profile, outside the project They never reach the repository, and load only in Development.

  4. What does ValidateOnStart do?

    1. Validates every request
    2. Checks the options when the app starts and stops it if they are wrong
    3. Encrypts settings
    4. Reloads settings
    Show answer

    Answer: B. Checks the options when the app starts and stops it if they are wrong DefaultPageSize = 500 stopped the API before any request.

  5. Where should a production database password go?

    1. appsettings.Production.json
    2. Environment variables or a secret store on the server
    3. The source code
    4. User secrets
    Show answer

    Answer: B. Environment variables or a secret store on the server Never in a committed file.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.

Fresher (0-2 yrs)​

F Where does an ASP.NET Core app read its configuration from, and in what order? concept

By default: appsettings.json, then appsettings.{Environment}.json, then user secrets in Development, then environment variables, then command-line arguments. A later source overrides an earlier one for the same key.

See the example · Permalink

F How do you read a connection string in ASP.NET Core? coding

Put it under ConnectionStrings in configuration, such as ConnectionStrings:SchoolDb, and read it with builder.Configuration.GetConnectionString("SchoolDb"). Check that it is not null or empty at startup, and keep passwords out of committed files.

See the example · Permalink

F What are user secrets, and when should you use them? concept

A per-user JSON file outside the project folder, linked by the project's UserSecretsId, for local development secrets such as your own connection string. They never reach Git and load only in the Development environment. They are not encrypted and not for servers.

See the example · Permalink

Mid (3-5 yrs)​

M How do you set a nested configuration key with an environment variable? coding

Replace each : with a double underscore: School__CurrentAcademicYear=2027-28 sets School:CurrentAcademicYear. The colon is not valid in environment variable names on every operating system; on the command line, --School:Name=... keeps the colon.

See the example · Permalink

M What is the options pattern, and why use it instead of reading IConfiguration directly? concept

It binds a configuration section to a class, such as SchoolOptions, registered with AddOptions<T>().BindConfiguration("School") and injected as IOptions<T>. It gives typed values, one place for defaults and validation, and classes that do not depend on configuration keys.

See the example · Permalink

M What is the difference between IOptions, IOptionsSnapshot, and IOptionsMonitor? concept

IOptions<T> reads the value once for the app's life. IOptionsSnapshot<T> is scoped and reads a fresh value each request, so file changes appear without a restart. IOptionsMonitor<T> is a singleton with the current value and change notifications, for long-lived services.

See the example · Permalink

Senior (6-9 yrs)​

S A deployment used a wrong page size setting and broke a screen two days later. How do you catch such errors earlier? scenario

Bind settings to options classes with validation attributes, and call ValidateDataAnnotations() and ValidateOnStart(). The app then refuses to start with a message naming the setting, so the deployment fails at once and the previous version keeps serving. Add custom Validate rules for checks attributes cannot express.

How to answer: Explain fail-fast configuration, how it interacts with deployment health checks, and give an example of the message an operator would see.

See the example · Permalink

S A developer committed a production connection string with a password to Git. What do you do? scenario

Treat it as leaked: change the password immediately, check access logs, and only then remove it from the history if needed. Then fix the cause: production secrets come from environment variables or a secret store, local ones from user secrets, and a secret scanner runs on every push.

How to answer: Order matters: rotate first, then clean up, then prevent. Mention that deleting the line does not remove it from history or from clones.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How would you design configuration and secret management for an API deployed to several environments? design

One build for all environments; safe defaults in appsettings.json; environment differences in environment files or host settings; secrets in a managed secret store, injected at startup with managed identities rather than stored passwords; options classes validated on start; and settings changes reviewed and versioned like code.

How to answer: Cover the build-once principle, where each kind of value lives, how secrets are accessed without credentials, validation, and auditing changes.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-70-configuration (code repository coming soon)
  • Files: src/SchoolApp.Api/appsettings.json, src/SchoolApp.Api/Options/SchoolOptions.cs, src/SchoolApp.Api/Program.cs, library-assignment/070-library-options.cs
  • Video: coming soon

What's next​

The API now has three endpoints and proper settings. Next: describing all of it in an OpenAPI document, so other developers and tools know every route, parameter, and response. Lesson 71: OpenAPI and testing endpoints

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.