Backend Development with .NET
Session 24
Deployment & Environment Configuration
Eng. Seif Mansour  ·  Andalusia Academy
Week 10  ·  2 hours
Session Goals

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

  • Manage configuration across Development, Staging, and Production environments
  • Explain the configuration provider order and predict which value wins
  • Store secrets safely with dotnet user-secrets, environment variables, and a cloud vault
  • Publish a .NET API with dotnet publish and run it outside Visual Studio
  • Containerize the API with a multi-stage Dockerfile and run it with docker run
  • Inject configuration into a container using the __ hierarchy separator
Agenda
Time Segment Type Duration
0:00Environment configuration in .NETTheory + Demo25 min
0:25Secrets managementDemo20 min
0:45Publishing a .NET APIDemo15 min
1:00Docker introTheory + Demo35 min
1:35Lab — containerize the Task APILab20 min
1:55Wrap-upDiscussion5 min
Environment Configuration
One codebase, many environments. The code never changes — only the values it reads.
In this section
The configuration provider stack
appsettings.{Environment}.json
ASPNETCORE_ENVIRONMENT
Reading config and the options pattern
The double-underscore separator
The Same Build, Three Environments

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.

Development
Local Postgres, verbose logs, Swagger UI on, detailed exception pages.
Host=localhost;Db=tasks_dev
Staging
Production-like database with test data, real auth, Swagger still useful for QA.
Host=staging-db.internal
Production
Real data, warning-level logs, no Swagger, secrets from a vault.
Host=prod-db.internal
The anti-pattern
if (isProduction) { connectionString = "..."; } hard-coded in C#. Configuration belongs outside the binary, not inside an if statement.
Configuration Provider Precedence
1   appsettings.json Base values shared by every environment — committed to Git 2   appsettings.{Environment}.json Per-environment overrides — Development, Staging, Production 3   Environment variables Injected by the host, the CI pipeline, or docker run — where secrets live 4   Command-line arguments --Jwt:Secret=... — highest priority, wins over everything above Loaded first lowest priority Loaded last highest priority
Later providers override earlier ones, key by key — not file by file.
Merging Happens Key by Key
  • appsettings.Production.json does not replace the base file — it is merged on top of it
  • Only the keys you redefine change; everything else falls through
  • Keep environment files small: they should contain differences, not copies
  • A missing key is null, 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)
Selecting the Environment

The ASPNETCORE_ENVIRONMENT variable decides which appsettings.{Environment}.json is loaded. If it is not set, .NET assumes Production.

terminal
# 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
Defaults bite
Forgetting to set the variable on a server does not crash anything — the app silently runs as Production and looks for a production database that may not exist. Always log the active environment at startup.
launchSettings.json — Local Development Only
Properties/launchSettings.json
{
  "profiles": {
    "http": {
      "commandName": "Project",
      "applicationUrl": "http://localhost:5080",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "Staging": {
      "commandName": "Project",
      "applicationUrl": "http://localhost:5081",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Staging"
      }
    }
  }
}
This file is read by dotnet run and Visual Studio — it is not published and has no effect on a deployed server.
Branching on the Environment
  • app.Environment exposes the active environment name
  • IsDevelopment(), IsStaging(), IsProduction() are string comparisons — case sensitive
  • Custom names work: IsEnvironment("QA")
  • Use it for behaviour (Swagger, error pages), never for values (hosts, keys)
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);
Reading Configuration Values
Program.cs
// 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)));
The : separates levels of the JSON hierarchy. Remember this — it becomes __ in environment variables.
The Options Pattern — Config as a Typed Object
Configuration/JwtOptions.cs & Program.cs
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.
JSON Keys Become Environment Variables

Environment variable names cannot contain : on Linux, so .NET accepts __ (double underscore) as the hierarchy separator.

Jwt:Secret  →  Jwt__SecretValid
ConnectionStrings:Default  →  ConnectionStrings__DefaultValid
Logging:LogLevel:Default  →  Logging__LogLevel__DefaultValid
Jwt_Secret  (single underscore)Ignored
JWT__SECRET  on LinuxCase risk
Practical rule
Match the casing of the JSON key exactly. Key lookup is case-insensitive on Windows and in .NET's own dictionary, but the shell that sets the variable is not always so forgiving.
Secrets Management
A secret in Git is a secret forever. Where each kind of secret actually belongs.
In this section
Why appsettings.json is the wrong home
The three-tier secrets hierarchy
dotnet user-secrets end to end
Pipeline variables and cloud vaults
What to do after a leak
What Counts as a Secret
Connection strings
Host, database name, username, and password in one convenient line for an attacker.
Never in a committed file
JWT signing key
Whoever holds it can mint a valid admin token for your API without logging in.
Never in a committed file
Third-party API keys
Mail, storage, and payment keys are billed to you no matter who uses them.
Never in a committed file
Not secret
Issuer names, token lifetimes, page sizes, log levels, allowed origins.
Safe in appsettings.json
Git never forgets
Deleting a secret in a later commit does not remove it — it stays in the history, in every clone, and in every fork. The only real fix is to rotate the secret.
The Secrets Hierarchy
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
The common thread
In all three tiers your C# code is identical — it still reads Configuration["Jwt:Secret"]. Only the provider supplying that value changes.
.NET User Secrets
terminal — run inside the API project folder
# 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
User secrets are loaded automatically — but only when the environment is Development.
Where Do User Secrets Actually Live?
  • A plain secrets.json keyed by the project's UserSecretsId
  • Outside the repository, so it can never be committed by accident
  • Not encrypted — it protects against leaks, not against someone using your laptop
  • Per developer: each teammate sets their own values
Team habit
Commit an appsettings.Example.json listing every required key with empty values, so a new teammate knows what to set.
# Windows
%APPDATA%\Microsoft\UserSecrets\
  <UserSecretsId>\secrets.json

# Linux / macOS
~/.microsoft/usersecrets/
  <UserSecretsId>/secrets.json
<PropertyGroup>
  <TargetFramework>net8.0</TargetFramework>
  <Nullable>enable</Nullable>
  <UserSecretsId>a1f4c2e0-6b7d-4f18-9c33-2e5a71b0d4ff</UserSecretsId>
</PropertyGroup>
Secrets in a CI/CD Pipeline
.github/workflows/deploy.yml
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
The pipeline injects secrets as environment variables at run time. They never appear in the repository or in the build log.
Production — A Cloud Secret Manager
  • Secrets are stored, versioned, and access-audited outside your app
  • The app authenticates with a managed identity — no key needed to fetch keys
  • Registered as one more configuration provider, so lookups stay unchanged
  • Rotation becomes a vault operation, not a redeploy
Naming
Vault names cannot contain : 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"];
Guardrails and Leak Response
# .gitignore
appsettings.Development.json
appsettings.*.Local.json
bin/
obj/
publish/
.env
*.pfx
  • Ignore local environment files before the first commit — Session 02 covered this
  • Commit a redacted example file so the required keys stay documented
  • Enable your host's secret scanning so a pushed key raises an alert
If a secret leaks
Rotate first, clean history second. Revoke the key, issue a new one, redeploy — then worry about rewriting Git history.
Key Concept
"Build the artifact once. Configure it many times. If a value differs between environments, it does not belong in the build."
— Session 24
Publishing a .NET API
Turning a project folder into a deployable artifact you can copy to any server
In this section
Debug vs Release
dotnet publish and its output
Framework-dependent vs self-contained
Running the published app
Binding to the right URL and port
From Source to Running Process
Source code .cs, .csproj, json dotnet restore NuGet packages dotnet build -c Release compiles to IL dotnet publish ./publish artifact Host runs it server or container Same artifact everywhere Build once in CI, then promote the identical output to Staging and Production. Only environment variables differ between the two deployments.
Debug vs Release Configuration
DebugRelease
OptimizationsDisabled — code maps cleanly to sourceEnabled — inlining, dead code removal
Debug symbolsFullPortable PDB, still useful for stack traces
AssertionsDebug.Assert activeCompiled out
Use forLocal development onlyAnything you deploy
Note
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.
Publishing the API
terminal
# 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
Publish restores, builds, and copies every runtime dependency into one folder. That folder is your deployable artifact.
What Lands in ./publish
publish/
  TaskManagerApi.Api.dll your code
  TaskManagerApi.Api launcher
  TaskManagerApi.Api.pdb
  appsettings.json
  appsettings.Production.json
  *.deps.json dependency graph
  *.runtimeconfig.json
  Npgsql.dll, Serilog.dll, ...
  wwwroot/ static files
  • No .cs files and no .csproj — only compiled output and content
  • launchSettings.json is not published; it is a developer-only file
  • All appsettings.*.json files are copied, so keep secrets out of every one of them
  • runtimeconfig.json tells the host which runtime version to load
Check
Open the published appsettings.Production.json and confirm it contains no real credentials.
Two Deployment Modes
Framework-dependent
Small output (a few MB). Requires the matching ASP.NET Core runtime installed on the host. Patch updates to the runtime apply without rebuilding your app. This is the default, and the right choice when you control the host image.
dotnet publish -c Release
Self-contained
Ships the runtime inside the folder (60–100 MB). Runs on a machine with no .NET installed. You own runtime patching. Needs a runtime identifier such as linux-x64, win-x64, or osx-arm64.
-r linux-x64 --self-contained true
With containers
Inside Docker you normally stay framework-dependent, because the base image already contains the ASP.NET Core runtime. That keeps the final image small.
Binding to the Right URL and Port
  • Without launchSettings.json, Kestrel defaults to port 8080 in .NET 8 container images
  • ASPNETCORE_URLS overrides the binding entirely
  • localhost refuses outside traffic — inside a container you must bind 0.0.0.0 or +
  • In production, terminate TLS at the reverse proxy or load balancer, not in Kestrel
# 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
Classic container bug
Binding http://localhost:8080 inside a container makes the API unreachable from the host even with the port published.
Docker
Packaging the app, the runtime, and the OS dependencies into one image that runs the same everywhere
In this section
Image, container, registry
Dockerfile instructions
Multi-stage builds
Layer caching and .dockerignore
Build, run, and inject configuration
Why Containers
  • Reproducibility. The image contains the OS libraries, the .NET runtime, and your app — "works on my machine" stops being an argument
  • Isolation. Two APIs needing different runtime versions run side by side on the same host
  • Portability. The same image runs on your laptop, in CI, and on any cloud that speaks OCI
  • Fast startup. A container shares the host kernel — it boots in milliseconds, not the tens of seconds a virtual machine needs
  • Declarative. The Dockerfile is the deployment documentation, and it is version controlled
Container vs virtual machine
A VM virtualizes hardware and runs a full guest OS. A container virtualizes the operating system and packages only user-space files. That is why images are measured in hundreds of megabytes rather than gigabytes.
Four Words You Need
TermWhat it isAnalogy
DockerfileA text recipe describing how to assemble an imageThe class definition
ImageAn immutable, layered snapshot built from the DockerfileThe compiled type
ContainerA running instance of an image, with its own filesystem and networkThe object instance
RegistryWhere images are pushed and pulled fromNuGet, but for images
Consequence
Containers are disposable. Anything written inside one is lost when it is removed — which is exactly why configuration arrives from outside and data lives in a database or a volume.
Dockerfile Instructions
FROMChooses the base image and starts a build stage
WORKDIRSets the working directory for later instructions
COPYCopies files from the build context into the image
RUNExecutes a command at build time, creating a new layer
EXPOSEDocuments the port the app listens on
ENVSets a default environment variable inside the image
ENTRYPOINTThe process that starts when the container runs
EXPOSE is documentation
It does not publish anything. Only docker run -p actually maps a host port to the container port.
Multi-Stage Build
STAGE: build mcr.microsoft.com/dotnet/sdk:8.0 Compilers, NuGet, MSBuild, full SDK dotnet restore  →  dotnet publish Roughly 800 MB — never shipped STAGE: final mcr.microsoft.com/dotnet/aspnet:8.0 Runtime only — no compilers on board Contains just /app/publish and its deps Roughly 220 MB — this is what deploys COPY --from=build /app/publish The build stage is discarded once the image is assembled. Source code, the SDK, and NuGet caches never reach production — a smaller image is also a smaller attack surface.
The Task API Dockerfile
Dockerfile — solution root
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"]
Three stages: base defines the runtime surface, build compiles, final assembles the shipped image.
Why Copy the .csproj First?
  • Every instruction produces a cached layer
  • A layer is reused until one of its inputs changes; then it and everything after it rebuilds
  • Copying only the .csproj before restore means NuGet restore is skipped whenever only C# files changed
  • Ordering instructions from least to most volatile turns a two-minute build into a ten-second one
# 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
The slow version
Putting COPY . . before RUN dotnet restore re-downloads every NuGet package on every single build.
.dockerignore
# .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 history
  • Local build output inside the image causes confusing "why is my old code running" bugs
  • A smaller context uploads faster to the Docker daemon and to CI
  • Excluding appsettings.Development.json keeps local secrets out of the image
Rule
Create .dockerignore at the same time as the Dockerfile, never later.
Build and Run the Image
terminal — solution root
# 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
The API is now reachable at http://localhost:8080/api/tasks — with no .NET SDK installed on the host.
Configuration Crosses the Container Boundary

Environment variables are how a container receives configuration — and __ is how nested keys survive the trip.

-e ASPNETCORE_ENVIRONMENT=ProductionSelects the appsettings file
-e Jwt__Secret=...Overrides Jwt:Secret
-e ConnectionStrings__Default=...Overrides the database
--env-file ./prod.envMany values at once
Baking secrets in with ENV in the DockerfileNever
Why never
ENV Jwt__Secret=... is stored in the image layer metadata. Anyone who can pull the image can read it with docker history.
Running the API and Its Database Together
compose.yaml
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:
Inside the compose network the database host is the service name, db — not localhost. The volume keeps data alive across restarts.
Commands You Will Use Daily
CommandWhat it does
docker psLists running containers; add -a to include stopped ones
docker logs -f tasksStreams the container's stdout — where your ASP.NET logs appear
docker exec -it tasks shOpens a shell inside a running container to inspect files
docker stop tasksSends SIGTERM so the app can shut down gracefully
docker imagesLists local images and their sizes
docker compose up --buildBuilds and starts every service defined in compose.yaml
Logging in containers
Write logs to the console, not to a file. The container runtime collects stdout — a log file inside a container disappears with the container. Session 23 covered structured console logging.
When the Container Will Not Talk to You
Connection refused on localhost:8080
The app bound to localhost inside the container, or -p was omitted.
Fix: ASPNETCORE_URLS=http://+:8080 and -p 8080:8080
Cannot reach the database
The connection string still says localhost, which inside a container means the container itself.
Fix: use the compose service name as the host
Container exits immediately
The app crashed on startup — usually a missing configuration value.
Fix: docker logs, then supply the -e variable
Old code keeps running
A cached layer, or stale bin/ copied in by COPY . ..
Fix: add .dockerignore, rebuild with --no-cache
Promoting to Production
One artifact moving through three environments, gaining nothing but configuration
In this section
The promotion flow
A pre-deployment checklist
Migrations on deploy
Health checks and readiness
Environment Promotion Flow
Development Config: appsettings.Development.json Secrets: dotnet user-secrets Database: local Postgres Swagger: on Logs: Debug Staging Config: appsettings.Staging.json Secrets: pipeline variables Database: staging copy Swagger: on for QA Logs: Information Production Config: appsettings.Production.json Secrets: cloud key vault Database: live, backed up Swagger: off Logs: Warning The image never changes as it moves right — only ASPNETCORE_ENVIRONMENT and the injected secrets do.
Database Migrations on Deploy
  • Database.Migrate() at startup is convenient but risky: several replicas may race, and a failed migration takes the app down
  • Prefer generating an idempotent SQL script and applying it as a deployment step
  • Never call EnsureCreated() in production — it bypasses migrations entirely
  • Migrations were covered in Session 14
# 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"
Note
The EF Core tools are part of the SDK. They are not present in the aspnet runtime image, so run migrations from the pipeline, not from inside the app container.
Pre-Deployment Checklist
01
ASPNETCORE_ENVIRONMENT is set explicitly on the host — never left to the default
02
No secret appears in any file inside the published artifact or the image
03
Swagger and the developer exception page are disabled outside Development
04
HTTPS redirection and HSTS are active, and the CORS origins list holds the real frontend domain (Session 21)
05
Options are validated with ValidateOnStart(), so a missing key fails fast and loudly
06
A health endpoint responds, and logs stream to the console where the host can collect them (Session 23)
Making the Container Report Its Health
Program.cs and Dockerfile
builder.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
An orchestrator restarts a container that fails its liveness probe, and withholds traffic until the readiness probe passes.
Lab — Containerize the Task API

Twenty minutes, working on your Task Management API project:

01
Create a Dockerfile in the solution root, plus a .dockerignore that excludes bin/, obj/, and appsettings.Development.json
02
Build the image with docker build -t task-manager-api . and confirm it appears in docker images
03
Run the container with -e flags for Jwt__Secret and ConnectionStrings__Default
04
Verify every endpoint via Postman against http://localhost:8080, including login and an authorized request
05
Run the container again without the JWT variable, read docker logs, and note how the failure appears
Summary
1
Configuration providers load in order — appsettings, then the environment file, then environment variables, then command-line arguments — and later wins key by key
2
ASPNETCORE_ENVIRONMENT selects the environment file; when it is unset, .NET assumes Production
3
Secrets have a three-tier home: user secrets locally, pipeline variables in CI, a cloud vault in production — and never a committed file
4
dotnet publish -c Release -o ./publish produces the deployable artifact; the same artifact serves every environment
5
A multi-stage Dockerfile builds with the SDK image and ships only the runtime image, keeping the result small and free of source code
6
Environment variables use __ where JSON keys use : — that one rule is how configuration reaches a container
What's Next

Session 25 — Capstone Project Review

  • Presenting your Task Management API end to end
  • Walking through auth, versioning, pagination, error handling, and tests
  • Demonstrating the API running from a container rather than from an IDE
  • Peer review against the capstone checklist, and where to go after this course
Before next session
Have your API running in a container with no secret stored in the repository. If you can hand a teammate only the image and a list of environment variables, and they can run it, you are ready to present.
Assignment

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 off
  • Every secret is read through configuration and supplied by dotnet user-secrets locally and -e variables in the container; none appears anywhere in Git
  • A JwtOptions class bound with AddOptions, ValidateDataAnnotations, and ValidateOnStart, so a missing secret fails at startup
  • A multi-stage Dockerfile plus .dockerignore; docker run -p 8080:8080 serves every endpoint
  • DEPLOYMENT.md lists the required environment variables and the build and run commands verbatim
Bonus
Add a compose.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.
Questions?
Session 24 — Deployment & Environment Configuration