By the end of this session, you will be able to:
dotnet user-secrets, environment variables, and a cloud vaultdotnet publish and run it outside Visual StudioDockerfile and run it with docker run__ hierarchy separator| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Environment configuration in .NET | Theory + Demo | 25 min |
| 0:25 | Secrets management | Demo | 20 min |
| 0:45 | Publishing a .NET API | Demo | 15 min |
| 1:00 | Docker intro | Theory + Demo | 35 min |
| 1:35 | Lab — containerize the Task API | Lab | 20 min |
| 1:55 | Wrap-up | Discussion | 5 min |
Your Task API must talk to a different database, log at a different level, and trust a different frontend origin in each environment — without recompiling.
if (isProduction) { connectionString = "..."; } hard-coded in C#. Configuration belongs outside the binary, not inside an if statement.
appsettings.Production.json does not replace the base file — it is merged on top of itnull, not an error — validate on startup// appsettings.json
{
"Logging": { "LogLevel": { "Default": "Information" } },
"Jwt": { "Issuer": "TaskManagerApi", "ExpiryMinutes": 60 },
"Cors": { "AllowedOrigins": [ "http://localhost:3000" ] }
}
// appsettings.Production.json
{
"Logging": { "LogLevel": { "Default": "Warning" } },
"Cors": { "AllowedOrigins": [ "https://tasks.myapp.com" ] }
}
// Effective in Production:
// Jwt:Issuer = TaskManagerApi (from base)
// Jwt:ExpiryMinutes = 60 (from base)
// Logging:LogLevel:Default = Warning (overridden)
The ASPNETCORE_ENVIRONMENT variable decides which appsettings.{Environment}.json is loaded. If it is not set, .NET assumes Production.
# Linux / macOS — current shell only
export ASPNETCORE_ENVIRONMENT=Production
# Windows — cmd.exe
set ASPNETCORE_ENVIRONMENT=Production
# Windows — PowerShell
$env:ASPNETCORE_ENVIRONMENT = "Production"
# One-off, without touching the shell at all
dotnet run --environment Staging
{
"profiles": {
"http": {
"commandName": "Project",
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Staging": {
"commandName": "Project",
"applicationUrl": "http://localhost:5081",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Staging"
}
}
}
}
dotnet run and Visual Studio — it is not published and has no effect on a deployed server.app.Environment exposes the active environment nameIsDevelopment(), IsStaging(), IsProduction() are string comparisons — case sensitiveIsEnvironment("QA")var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
app.Logger.LogInformation(
"Starting in {Environment}",
app.Environment.EnvironmentName);
// Flat value by colon-delimited path
var issuer = builder.Configuration["Jwt:Issuer"];
// Typed value with a fallback
var expiry = builder.Configuration.GetValue<int>("Jwt:ExpiryMinutes", 60);
// Connection strings have a dedicated helper
var connection = builder.Configuration.GetConnectionString("Default");
// Arrays bind to collections
var origins = builder.Configuration
.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(options =>
options.AddPolicy("Frontend", p => p.WithOrigins(origins)));
: separates levels of the JSON hierarchy. Remember this — it becomes __ in environment variables.public class JwtOptions
{
public const string SectionName = "Jwt";
[Required] public string Issuer { get; set; } = string.Empty;
[Required, MinLength(32)] public string Secret { get; set; } = string.Empty;
[Range(5, 1440)] public int ExpiryMinutes { get; set; } = 60;
}
// Program.cs
builder.Services
.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Anywhere in the app
public class TokenService(IOptions<JwtOptions> options)
{
private readonly JwtOptions _jwt = options.Value;
}
ValidateOnStart() turns a missing secret into a startup crash instead of a 500 at 3 a.m.Environment variable names cannot contain : on Linux, so .NET accepts __ (double underscore) as the hierarchy separator.
| Environment | Use | Stored where |
|---|---|---|
| Development | dotnet user-secrets |
Your user profile, outside the project directory |
| CI/CD | Environment variables injected by the pipeline | Encrypted pipeline variables or GitHub Actions secrets |
| Production | Cloud secret manager | Azure Key Vault, AWS Secrets Manager, HashiCorp Vault |
Configuration["Jwt:Secret"]. Only the provider supplying that value changes.
# Adds a UserSecretsId GUID to the .csproj
dotnet user-secrets init
# Store values — keys use the same colon syntax as appsettings.json
dotnet user-secrets set "Jwt:Secret" "my-dev-secret-key-32chars-minimum"
dotnet user-secrets set "ConnectionStrings:Default" "Host=localhost;Database=tasks_dev;Username=postgres;Password=dev"
# Inspect what is stored
dotnet user-secrets list
# Remove one key, or wipe them all
dotnet user-secrets remove "Jwt:Secret"
dotnet user-secrets clear
secrets.json keyed by the project's UserSecretsIdappsettings.Example.json listing every required key with empty values, so a new teammate knows what to set.
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<UserSecretsId>a1f4c2e0-6b7d-4f18-9c33-2e5a71b0d4ff</UserSecretsId>
</PropertyGroup>
name: Deploy Task API
on:
push:
branches: [ main ]
jobs:
publish:
runs-on: ubuntu-latest
env:
ASPNETCORE_ENVIRONMENT: Production
Jwt__Secret: ${{ secrets.JWT_SECRET }}
ConnectionStrings__Default: ${{ secrets.DB_CONNECTION }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet test --configuration Release
- run: dotnet publish -c Release -o ./publish
: either — Azure Key Vault maps -- to the hierarchy separator, so Jwt--Secret becomes Jwt:Secret.
if (builder.Environment.IsProduction())
{
var vaultUri = new Uri(
builder.Configuration["KeyVault:Uri"]!);
builder.Configuration.AddAzureKeyVault(
vaultUri,
new DefaultAzureCredential());
}
// Unchanged everywhere else in the app
var secret = builder.Configuration["Jwt:Secret"];
# .gitignore
appsettings.Development.json
appsettings.*.Local.json
bin/
obj/
publish/
.env
*.pfx
| Debug | Release | |
|---|---|---|
| Optimizations | Disabled — code maps cleanly to source | Enabled — inlining, dead code removal |
| Debug symbols | Full | Portable PDB, still useful for stack traces |
| Assertions | Debug.Assert active | Compiled out |
| Use for | Local development only | Anything you deploy |
dotnet publish defaults to Release in .NET 8, but earlier SDKs defaulted to Debug. Always pass -c Release explicitly so the command means the same thing on every machine.
# Framework-dependent build — needs the .NET runtime on the host
dotnet publish -c Release -o ./publish
# Run the published app (no Visual Studio, no SDK required)
dotnet ./publish/TaskManagerApi.Api.dll
# Or run the generated native launcher directly
./publish/TaskManagerApi.Api
# Self-contained — bundles the runtime, no install needed on the host
dotnet publish -c Release -r linux-x64 --self-contained true -o ./publish-linux
.cs files and no .csproj — only compiled output and contentlaunchSettings.json is not published; it is a developer-only fileappsettings.*.json files are copied, so keep secrets out of every one of themruntimeconfig.json tells the host which runtime version to loadappsettings.Production.json and confirm it contains no real credentials.
linux-x64, win-x64, or osx-arm64.
launchSettings.json, Kestrel defaults to port 8080 in .NET 8 container imagesASPNETCORE_URLS overrides the binding entirelylocalhost refuses outside traffic — inside a container you must bind 0.0.0.0 or +# Listen on all interfaces, port 8080
export ASPNETCORE_URLS=http://+:8080
# Multiple bindings, semicolon separated
export ASPNETCORE_URLS="http://+:8080;https://+:8443"
# .NET 8 shorthand for the HTTP port only
export ASPNETCORE_HTTP_PORTS=8080
dotnet ./publish/TaskManagerApi.Api.dll
http://localhost:8080 inside a container makes the API unreachable from the host even with the port published.
Dockerfile is the deployment documentation, and it is version controlled| Term | What it is | Analogy |
|---|---|---|
| Dockerfile | A text recipe describing how to assemble an image | The class definition |
| Image | An immutable, layered snapshot built from the Dockerfile | The compiled type |
| Container | A running instance of an image, with its own filesystem and network | The object instance |
| Registry | Where images are pushed and pulled from | NuGet, but for images |
docker run -p actually maps a host port to the container port.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["TaskManagerApi.Api/TaskManagerApi.Api.csproj", "TaskManagerApi.Api/"]
RUN dotnet restore "TaskManagerApi.Api/TaskManagerApi.Api.csproj"
COPY . .
RUN dotnet publish "TaskManagerApi.Api/TaskManagerApi.Api.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "TaskManagerApi.Api.dll"]
base defines the runtime surface, build compiles, final assembles the shipped image..csproj before restore means NuGet restore is skipped whenever only C# files changed# Cached unless the csproj changes
COPY ["TaskManagerApi.Api/TaskManagerApi.Api.csproj", "TaskManagerApi.Api/"]
RUN dotnet restore "TaskManagerApi.Api/TaskManagerApi.Api.csproj"
# Invalidated on every source edit
COPY . .
RUN dotnet publish -c Release -o /app/publish
COPY . . before RUN dotnet restore re-downloads every NuGet package on every single build.
# .dockerignore
bin/
obj/
.git/
.vs/
publish/
appsettings.Development.json
**/*.user
Dockerfile
README.md
COPY . . copies the whole build context — without this file that includes bin/, obj/, and your Git historyappsettings.Development.json keeps local secrets out of the image.dockerignore at the same time as the Dockerfile, never later.
# Build. The trailing dot is the build context, not a typo.
docker build -t task-manager-api .
# Run, mapping host port 8080 to container port 8080
docker run -p 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
-e Jwt__Secret=my-production-secret \
task-manager-api
# Detached, named, and removed automatically when stopped
docker run -d --rm --name tasks -p 8080:8080 task-manager-api
http://localhost:8080/api/tasks — with no .NET SDK installed on the host.Environment variables are how a container receives configuration — and __ is how nested keys survive the trip.
ENV Jwt__Secret=... is stored in the image layer metadata. Anyone who can pull the image can read it with docker history.
services:
api:
build: .
ports:
- "8080:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: "Host=db;Port=5432;Database=tasks;Username=postgres;Password=${DB_PASSWORD}"
Jwt__Secret: "${JWT_SECRET}"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: "${DB_PASSWORD}"
volumes:
- tasks-data:/var/lib/postgresql/data
volumes:
tasks-data:
db — not localhost. The volume keeps data alive across restarts.| Command | What it does |
|---|---|
docker ps | Lists running containers; add -a to include stopped ones |
docker logs -f tasks | Streams the container's stdout — where your ASP.NET logs appear |
docker exec -it tasks sh | Opens a shell inside a running container to inspect files |
docker stop tasks | Sends SIGTERM so the app can shut down gracefully |
docker images | Lists local images and their sizes |
docker compose up --build | Builds and starts every service defined in compose.yaml |
localhost inside the container, or -p was omitted.localhost, which inside a container means the container itself.bin/ copied in by COPY . ..Database.Migrate() at startup is convenient but risky: several replicas may race, and a failed migration takes the app downEnsureCreated() in production — it bypasses migrations entirely# Safe: a script reviewers can read
dotnet ef migrations script --idempotent -o migrate.sql
# Acceptable for a single-instance deployment
dotnet ef database update \
--connection "$ConnectionStrings__Default"
aspnet runtime image, so run migrations from the pipeline, not from inside the app container.
ASPNETCORE_ENVIRONMENT is set explicitly on the host — never left to the defaultValidateOnStart(), so a missing key fails fast and loudlybuilder.Services
.AddHealthChecks()
.AddDbContextCheck<TaskDbContext>("database");
var app = builder.Build();
app.MapHealthChecks("/health/live");
app.MapHealthChecks("/health/ready");
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health/live || exit 1
Twenty minutes, working on your Task Management API project:
Dockerfile in the solution root, plus a .dockerignore that excludes bin/, obj/, and appsettings.Development.jsondocker build -t task-manager-api . and confirm it appears in docker images-e flags for Jwt__Secret and ConnectionStrings__Defaulthttp://localhost:8080, including login and an authorized requestdocker logs, and note how the failure appearsASPNETCORE_ENVIRONMENT selects the environment file; when it is unset, .NET assumes Productiondotnet publish -c Release -o ./publish produces the deployable artifact; the same artifact serves every environment__ where JSON keys use : — that one rule is how configuration reaches a containerSession 25 — Capstone Project Review
Make your Task Management API fully deployable: environment-aware configuration, zero committed secrets, and a working container image. Add a short DEPLOYMENT.md with the exact commands to build and run it.
appsettings.Production.json exists and overrides only what differs — log level, allowed CORS origins, and Swagger being offdotnet user-secrets locally and -e variables in the container; none appears anywhere in GitJwtOptions class bound with AddOptions, ValidateDataAnnotations, and ValidateOnStart, so a missing secret fails at startupDockerfile plus .dockerignore; docker run -p 8080:8080 serves every endpointDEPLOYMENT.md lists the required environment variables and the build and run commands verbatimcompose.yaml that runs the API together with a Postgres service and a named volume, so docker compose up --build starts a complete working stack from a clean machine. Include a HEALTHCHECK on the API service and explain in DEPLOYMENT.md why the connection string uses the service name instead of localhost.