Configuration and Environments: appsettings, Secrets, Options – Web API | Lesson 70
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.jsonand 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:
| Order | Source | Typical use |
|---|---|---|
| 1 | appsettings.json | Defaults shared by every environment, committed to Git |
| 2 | appsettings.{Environment}.json | Differences for one environment, such as Development |
| 3 | User secrets (Development only) | Your own local secrets, outside the repository |
| 4 | Environment variables | Server settings and secrets set by the host |
| 5 | Command-line arguments | One-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.
| Interface | Gives | Use for |
|---|---|---|
IOptions<T> | One value, read once | Settings 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
{
"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
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
// 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
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
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:
{"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:
School__CurrentAcademicYear=2027-28 dotnet run --project src/SchoolApp.Api -- --School:Name="Test School"
{"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:
School__DefaultPageSize=500 dotnet run --project src/SchoolApp.Api
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:
{
"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
| Setting | Where |
|---|---|
| Defaults safe for everyone (page size, school name) | appsettings.json |
| Development-only differences (more logging) | appsettings.Development.json |
| Your local connection string or test passwords | User secrets |
| Production connection strings, keys, passwords | Environment variables or a secret store on the server |
| A one-off change for one run | Command-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
- Passwords in
appsettings.json. They end up in Git history forever. Fix: user secrets locally, environment variables or a vault on servers. School:Nameas an environment variable on Linux. The colon is not allowed there. Fix:School__Name.- Reading
Configuration["..."]all over the code. Typos fail silently withnull. Fix: options classes. - No validation. A wrong value breaks the first request that uses it, maybe days later. Fix:
ValidateDataAnnotations().ValidateOnStart(). - Expecting user secrets on the server. They load only in
Development. Fix: configure servers through environment variables or a secret store.
In real 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 whenDefaultPageSizewas 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:
{"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": {
"Name": "NexCoding Public School Library",
"LoanDays": 14,
"FinePerDay": 2
}
}
// 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
appsettings.json says Name is A; an environment variable says B. What does the app use?
Show answer
Answer: B. B Environment variables come later, so they override appsettings.json.
How do you write the key School:Name as an environment variable?
Show answer
Answer: B. School__Name A double underscore stands for the colon.
Where are user secrets stored?
Show answer
Answer: B. In a file in your user profile, outside the project They never reach the repository, and load only in Development.
What does ValidateOnStart do?
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.
Where should a production database password go?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.