Skip to main content
ANVISoftware Solutions
Lesson 14 of 14Advanced20 min

Secure Deployment

By the end of this lesson

Harden configuration and reduce attack surface at deploy time.

The code has been written carefully. Authorisation is enforced per request, queries are parameterised, output is encoded, secrets are in a store, dependencies are current. The application is now deployed, and a set of decisions that have nothing to do with your source files decide how much of it is exposed.

There are two quantities to reduce. What is reachable: endpoints, ports, headers, error detail, anything that answers from outside. And what is present: every binary, tool, package and file sitting in the running environment, whether or not anything calls it. A compiler in a production container is not reachable from the internet, and it is still available to a process that ends up running there.

Most of this is configuration, and most of it is one-time work. That is the appeal of it — a small number of deliberate choices, made once per service, that hold until someone changes them. The rest of this lesson is those choices for the orders API running in a container.

What should not be in a production deployment. Each item is something that helps during development:

  • The developer exception page, which prints the exception, the stack trace, the failing source line and the request's headers and cookies to whoever provoked the error
  • Detailed error responses of any kind — a database message, a file path, a stack trace or a framework version returned to a caller describes your internals to anyone who can cause a failure
  • Interactive API documentation that executes requests against the running service. The schema document itself may be published deliberately; a UI with a send button is a different decision
  • Debug, diagnostic and profiling endpoints. These are added for one afternoon and are rarely removed, because nothing breaks while they stay
  • Directory browsing on static files, which turns a misplaced backup or export in a served folder into a public download
  • Headers announcing the server, framework and version. Removing them does not stop anybody determined, and it removes a free hint
  • Seed data, sample records and test accounts, particularly any account with a known password and elevated rights
  • Build tooling: the SDK, compilers, package caches, test projects and source. None of it serves traffic, and all of it carries its own advisories
  • Health endpoints that list every dependency, its host name and its error text. A liveness check can return a plain 200 and keep the detail internal
Program.cs — what differs between development and production
C#
var builder = WebApplication.CreateBuilder(args);

// Do not announce the server and its version. Small, free, permanent.
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);

// One consistent error shape for clients, with no internal detail in it.
builder.Services.AddProblemDetails();

builder.Services.AddHealthChecks();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    // Development only. This page exists to tell you everything, which is
    // exactly why it must never answer a request in production.
    app.UseDeveloperExceptionPage();

    // The schema document, for local tooling. No execute-against-production UI.
    app.MapOpenApi();
}
else
{
    // The detail goes to the logs with a correlation id the caller can quote.
    app.UseExceptionHandler();
    app.UseStatusCodePages();
    app.UseHsts();
}

app.UseHttpsRedirection();

// Liveness only: a plain 200 or 503, with no dependency names in the body.
app.MapHealthChecks("/health/live");

app.MapControllers().RequireAuthorization();

app.Run();
  • Every branch here depends on the environment being set correctly, which makes ASPNETCORE_ENVIRONMENT one of the more consequential settings in the deployment. A production container running with it set to Development has the exception page, and nothing else in this file matters. Assert the value at startup and log it once, so the mistake is visible.
  • UseExceptionHandler with problem details gives every failure the same shape: a status code, a title, and no internals. An unhandled exception that reaches a caller can otherwise include a file path from the build machine, a table name, or a fragment of a connection string.
  • The caller gets a correlation id and nothing else. That is not obstruction — it is the pairing from the logging lesson, where the detail is recorded in a place with tighter access and the id is what connects the two.
  • AddServerHeader = false removes Kestrel's header. Check what your reverse proxy or platform adds as well, since the removal has to happen at whichever component answers last.
  • Publishing the OpenAPI schema is a judgement call and depends on the API. What should not ship is a documentation UI that composes and sends requests to production, because it is a convenient console for anything that reaches it.
  • There is no UseStaticFiles call, because this API serves no files. Add it only if it does, and never enable directory browsing — it is off by default, and it gets switched on while debugging a path problem and left on.
  • The health endpoint returns liveness only. A detailed report naming each dependency, its host and its error text is a map of your internals available without authentication, and it is the most common accidental disclosure in an otherwise careful deployment. Keep the detailed check on an internal-only route or port.
  • RequireAuthorization on the endpoints repeats the closed-by-default idea from the first lesson. Deployment hardening reduces what is exposed; it does not substitute for the endpoint refusing on its own.
Deployment settings for the orders API container
YAML
securityContext:
  runAsNonRoot: true
  runAsUser: 10001              # matches the USER declared in the image
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

resources:
  limits:
    cpu: "1"
    memory: "512Mi"

env:
  - name: ASPNETCORE_ENVIRONMENT
    value: "Production"
  - name: ASPNETCORE_URLS
    value: "http://+:8080"      # above 1024, so binding needs no privilege

volumeMounts:
  - name: tmp
    mountPath: /tmp             # the one writable path the process needs
  • runAsNonRoot is a guard rather than the fix. The image itself should declare a non-root USER; this setting makes the platform refuse to start an image that would run as root, so the mistake is caught at deploy time instead of running unnoticed for a year.
  • The user id has to match what the image declares, and the application has to listen above port 1024, because binding a lower port needs privilege. That is why the URL is set to 8080 — a container listening on 80 is the usual reason a non-root attempt fails.
  • allowPrivilegeEscalation: false and dropping all capabilities remove abilities the application never uses. The reasoning is narrow and worth stating: they do not stop a process being compromised, they reduce what it can do next.
  • A read-only root filesystem means the process cannot modify its own image at run time. This one finds real issues on first attempt — temporary files, data protection keys, a cache directory — so expect to mount a writable volume for each genuine need, which is the point: each need becomes explicit.
  • Resource limits are about availability rather than confidentiality. One container consuming a node's memory takes its neighbours with it, and an unbounded request can do that on purpose or by accident.
  • ASPNETCORE_ENVIRONMENT is set explicitly, because every environment-gated branch in the previous section depends on it. Do not rely on a default, and do not rely on it having been set correctly on the image.
  • Only non-secret values appear here. A deployment manifest is committed, templated, copied between environments and readable by anyone with access to the cluster, so secrets come from the store as the earlier lesson described.

The deployment itself has a security posture. These four are easy to leave loose because nothing visibly breaks when they are:

Least privilege for the deployment identity
The pipeline's credential should be able to deploy this application to this environment, and nothing else. It frequently ends up with broad rights because that was quicker, which means anyone who can change the pipeline definition, or influence a build, can reach everything. Scope it per environment, keep no standing administrative rights, and require approval for production.
Separate credentials per environment
A development credential must not reach production data. Shared credentials mean every developer effectively has production access, no credential can be rotated without breaking something unrelated, and an access log tells you which credential was used rather than who used it. Separate identities per environment make all three tractable.
Separate data, not only separate credentials
A production database restored into development brings production's obligations and loses production's protections: broader access, weaker monitoring, laptops, backups nobody tracked. If realistic data is needed for testing, mask or generate it. This one is usually decided by habit rather than by a decision, which is what makes it worth raising.
Runtime only in the image
A multi-stage build compiles in one stage and copies only the published output into a runtime base image. The SDK, compilers, package caches, test projects and source stay behind. The benefit is not only size: every one of those carries its own advisories, and the dependency lesson's report gets shorter when they are not shipped.

The same application, configured for two purposes. Copying a development setting into production is how most of this goes wrong:

 DevelopmentProduction
Unhandled errorsFull exception page, stack trace and source lineA generic response with a correlation id, detail in the logs
API documentationSchema and an interactive UISchema only if publishing it is intended; no execute UI
Logging levelDebug, verbose, whatever helps todayInformation, with security events retained deliberately
DataSeeded or generated, with test accountsReal data, no seed accounts, no known passwords
SecretsUser secrets on the developer's machineSecret store or an identity with no credential at all
Container userWhatever runs locally, often root, and it rarely mattersA non-root user, no added capabilities, read-only root filesystem
Image contentsSDK present, source mounted, hot reload runningPublished output on a runtime base image, nothing else
TransportThe local development certificateA trusted certificate, HTTP redirected, HSTS enabled

Summary

  • Reduce two things at deploy time: what is reachable from outside, and what is present in the running environment
  • Turn off detailed errors, debug endpoints, directory browsing and version headers in production, and gate them on an asserted environment
  • Run as a non-root user with capabilities dropped and a read-only filesystem, and ship runtime output rather than build tooling
  • Give the deployment identity only the rights it uses, and separate credentials and data per environment
  • Hardening is a setting somebody can change, so enforce it with a pipeline check rather than a one-time effort

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Harden the orders container

The orders API currently ships as a single-stage image built from the .NET SDK, runs as root, listens on port 80, and is deployed with the latest tag. The application configuration is identical in every environment.

List the changes you would make, and for each one say what it reduces. Put them in the order you would do them.

Show solution

First, split the build. Compile in an SDK stage, publish, and copy only the output into a runtime base image. This removes the compilers, package caches, test projects and source from the running environment, which shortens your vulnerability report as well as the image. It is first because it changes what the later steps are working with.

Second, declare a non-root USER in the image and move the listening port above 1024, then set runAsNonRoot and a matching user id in the deployment. These go together — the platform setting alone fails to start, and the image setting alone is easy to lose in a rebuild. This reduces what a compromised process begins with.

Third, separate the configuration per environment, with ASPNETCORE_ENVIRONMENT set explicitly and asserted at startup. This is what makes the exception page, verbose logging and any development-only endpoint conditional in fact rather than in intention.

Fourth, deploy an immutable tag or digest, and record which build produced it. This does not reduce exposure on its own; it makes every other answer knowable, which is what an investigation depends on.

Then the smaller items: drop capabilities, disable privilege escalation, set a read-only root filesystem with a writable mount for temporary files, add resource limits, remove the server header, and check that the health endpoint reveals nothing.

Last, make it stick. Add a pipeline check that fails on an image running as root and on a missing environment assertion. Every item above is a setting someone can change back, and the check is the difference between hardening the deployment once and having a hardened deployment.

Think about it

One environment variable

A production container is deployed with ASPNETCORE_ENVIRONMENT set to Development, by mistake, and nobody notices for a month.

Work through what is now true of that deployment. Then say what you would change so that this is visible rather than silent.

Show solution

The developer exception page is answering. Any request that causes an unhandled exception returns the exception type and message, the stack trace, the failing source line, and the request's headers and cookies — to whoever caused it. Causing one is frequently as simple as sending a value of the wrong shape.

Every other environment-gated branch is on too, and that is the part that catches teams out, because you have to go and read them. Interactive API documentation, seeding, verbose logging, a relaxed CORS policy for a local origin, a development-only stub for an external service, a test authentication handler. Whatever the codebase gates on this one check is now live.

Logging is verbose, which has two effects. The store fills with framework detail nobody reviewed for sensitivity, and the security events from the previous lesson are buried in the volume — so the month of exposure is also a month with poor records of it.

Configuration loading changes. The Development appsettings file is layered in, and user secrets are registered as a provider. On a server that usually means they contribute nothing, and it does mean the effective configuration is not the one anyone reviewed.

What to change so it is visible: assert the expected environment at startup and refuse to start if it is wrong. The deployment knows which environment it is; the application can check that the two agree. Log the environment name once at boot, so it is in the first line of every log stream. Add a pipeline check on the deployed manifest. And add a smoke test that provokes a handled error against the deployed service and asserts that the response contains no stack trace — that one catches the whole class of problem from the outside, which is where it matters.

The general lesson is worth extracting. A deployment setting that changes behaviour everywhere and is checked nowhere is a poor arrangement. Either assert it or stop depending on it.

Saved in this browser only.

End of the published lessons

That is everything written so far in Security

More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.