Mert Özen Further With Every Line
DevOps SSL/TLS Certificates and HTTPS — #04

Certificates in .NET and Kestrel: dev-certs, Loading PEM/PFX, SNI, Reload on Renewal and HttpClient Validation

Mert Özen Nov 14, 2026 15 min 4 views
Certificates in .NET and Kestrel: dev-certs, Loading PEM/PFX, SNI, Reload on Renewal and HttpClient Validation

Kestrel reloads a PEM or PFX certificate from configuration without a restart, but it watches only Path, notices the change in about five seconds and restarts the endpoint; as a non-root user it cannot read Certbot's files, so a deploy hook is needed. With real runs we look at dev-certs' SSL_CERT_DIR setting on Linux, SNI with ServerCertificateSelector, and trusting an internal CA in HttpClient with CustomRootTrust instead of turning validation off.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404, ASP.NET Core runtime 10.0.15), Certbot 5.10.0, Pebble (Let's Encrypt's test server) and OpenSSL 3 on Docker 29, in the official Ubuntu 24.04-based .NET images; Windows-specific behavior was not run in this environment and is taken from the documentation.

Guess First

Bookstore.Api runs on a Linux server with no reverse proxy in front, terminating TLS in Kestrel. Certbot on the same machine obtains the certificate. Since .NET 8 the official container images run the application as the non-root app user, and the team applies the same rule on the server. appsettings.Production.json gives Kestrel the path Certbot provides: /etc/letsencrypt/live/api.bookstore.example/fullchain.pem.

Two questions. First: does the application start? Second: assuming it does, when Certbot renews the certificate, does Kestrel need a restart, and if not, how long until the new certificate is served and what happens to open connections meanwhile? We will see both with real runs.

Development: dotnet dev-certs

On a development machine Kestrel's HTTPS (HTTP Secure) uses a development certificate generated by the SDK: CN=localhost, self-signed, valid for one year. On Windows, dotnet dev-certs https --trust adds the certificate to the user's Root store and shows a confirmation dialog; on macOS it adds it to the Keychain. On Linux it depends on the distribution, and Microsoft officially supports only Ubuntu and Fedora. I tried it in the Ubuntu 24.04-based SDK image:

$ dotnet dev-certs https --check
No valid certificate found.
$ dotnet dev-certs https --trust
Trusting the HTTPS development certificate was requested. Trust is per-user and may require additional configuration. See https://aka.ms/dev-certs-trust for more information.
[110] For OpenSSL trust to take effect, '$HOME/.aspnet/dev-certs/trust' must be listed in the SSL_CERT_DIR environment variable. For example, `export SSL_CERT_DIR="$HOME/.aspnet/dev-certs/trust:/usr/lib/ssl/certs"`. See https://aka.ms/dev-certs-trust for more information.
There was an error trusting the HTTPS developer certificate. It will be trusted by some clients but not by others.
$ ls ~/.aspnet/dev-certs/trust
aspnetcore-localhost-85133820C795076E59C2B7F4FE25D736E5900635.pem
ce275665.0

The word "error" is misleading. The tool generated the certificate and put it into a hashed folder that OpenSSL can read, but for OpenSSL-based clients (curl, .NET on Linux) to look in that folder, you have to set SSL_CERT_DIR yourself. Browser trust additionally needs the certutil tool. Once it is set, the check passes:

$ export SSL_CERT_DIR="$HOME/.aspnet/dev-certs/trust:/usr/lib/ssl/certs"
$ dotnet dev-certs https --check --trust
A trusted certificate was found: 7FB230F0FBB47BE0A357A82BD884ED8BA49AADA0 - CN=localhost - Valid from 2026-11-13 21:58:57Z to 2027-11-13 21:58:57Z - IsHttpsDevelopmentCertificate: true - IsExportable: true

If you run the application in a container during development, the dev certificate is not there. The official SDK image ships with DOTNET_GENERATE_ASPNET_CERTIFICATE=false, and the runtime image has no SDK at all. The right way is to export the certificate on the host and mount it read-only into the container:

$ dotnet dev-certs https -ep ./devcert/bookstore-dev.pem --format Pem --no-password
The HTTPS developer certificate was generated successfully.
$ docker run -d --name tlsseries-devcert -e ASPNETCORE_HTTPS_PORTS=8443 \
    -e Kestrel__Certificates__Default__Path=/https/bookstore-dev.pem \
    -e Kestrel__Certificates__Default__KeyPath=/https/bookstore-dev.key \
    -v ./devcert:/https:ro -v ./app/Bookstore.Api:/app:ro -w /app \
    mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
$ docker run --rm --network container:tlsseries-devcert -v ./devcert:/https:ro mcr.microsoft.com/dotnet/sdk:10.0 \
    sh -c 'curl -sS --cacert /https/bookstore-dev.pem https://localhost:8443/api/health; curl -sS https://localhost:8443/api/health 2>&1 | head -1'
ok from localhost
curl: (60) SSL certificate problem: self-signed certificate

Besides localhost, the exported certificate's SAN list also contains names like host.docker.internal and *.dev.localhost; you can check with openssl x509 -ext subjectAltName. Microsoft's warning is clear: don't generate a development certificate inside an image meant for distribution. A dev cert's private key should be separate on every developer's machine and valid only there.

Giving Kestrel a Certificate: PEM, PFX and the Store

In production, giving the certificate through configuration is the most flexible way. In the "Getting to Know Kestrel" post of the Web Servers and Hosting series we saw the basics of Path/KeyPath and the SNI JSON (JavaScript Object Notation). Bookstore.Api's config in this post serves two internal certificates next to the public one:

{
  "Logging": {
    "LogLevel": {
      "Microsoft.AspNetCore.Server.Kestrel": "Debug"
    }
  },
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://*:8443",
        "Certificate": {
          "Path": "/etc/bookstore/tls/fullchain.pem",
          "KeyPath": "/etc/bookstore/tls/privkey.pem"
        },
        "Sni": {
          "legacy.bookstore.internal": {
            "Certificate": {
              "Path": "/etc/bookstore/internal/legacy.fullchain.pem",
              "KeyPath": "/etc/bookstore/internal/legacy.key"
            }
          },
          "*.bookstore.internal": {
            "Certificate": {
              "Path": "/etc/bookstore/internal/partner-api.fullchain.pem",
              "KeyPath": "/etc/bookstore/internal/partner-api.key"
            }
          },
          "*": {
            "Certificate": {
              "Path": "/etc/bookstore/tls/fullchain.pem",
              "KeyPath": "/etc/bookstore/tls/privkey.pem"
            }
          }
        }
      }
    }
  }
}

In SNI matching an exact name comes before a wildcard; if several wildcards match, the longest one wins. * catches everything else, including clients that send no SNI. Without that entry, Kestrel refuses a connection arriving with a name it doesn't know.

There are three kinds of source. Path with KeyPath is for PEM files and is the most natural match for Certbot. Path with Password is for PFX (PKCS #12); it is common on Windows and with certificates downloaded from Azure Key Vault. Subject with Store reads from the Windows certificate store. Don't write the PFX password into appsettings.json. Provide it with the Kestrel__Certificates__Default__Password environment variable or the Key Vault configuration provider; managing the password itself is the subject of the Configuration and Secret Management series. Reading the certificate from the Windows store is the way to avoid file permissions and keep the key non-exportable, but then renewal has to be built around the store too.

Certbot with Kestrel: Permissions and the Deploy Hook

The first Guess First question. I obtained the certificate with Certbot's standalone mode, which solves the challenge with a temporary server it opens on port 80 itself. Then I started Kestrel as the app user with the paths pointing directly at live/:

$ certbot certonly --standalone -d api.bookstore.example --preferred-profile shortlived \
    --server https://pebble:14000/dir -m ops@bookstore.example --agree-tos --no-eff-email -n \
    --deploy-hook /usr/local/bin/bookstore-cert-deploy.sh
Requesting a certificate for api.bookstore.example
This certificate expires on 2026-11-19.
$ ls -ld /etc/letsencrypt/archive /etc/letsencrypt/live | awk '{print $1,$3,$4,$9}'
drwx------ root root /etc/letsencrypt/archive
drwx------ root root /etc/letsencrypt/live
$ dotnet Bookstore.Api.dll
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      System.UnauthorizedAccessException: Access to the path '/etc/letsencrypt/live/api.bookstore.example/fullchain.pem' is denied.
       ---> System.IO.IOException: Permission denied

The application doesn't start. Certbot creates the live/ and archive/ folders so only root can enter them (0700), and the private key with 0600. That is exactly how a private key should be. "Fixing" it by running Kestrel as root is the worst option. Loosening the folder permissions opens all certificates to all users. The right way is a deploy hook, running on renewal, that copies only this application's certificate to a place only the application's group can read:

#!/bin/sh
# Certbot deploy hook: copy the renewed certificate where Kestrel (user app) can read it
set -e
dest=/etc/bookstore/tls
install -o root -g app -m 0640 "$RENEWED_LINEAGE/privkey.pem" "$dest/privkey.pem"
install -o root -g app -m 0644 "$RENEWED_LINEAGE/fullchain.pem" "$dest/fullchain.pem.new"
mv "$dest/fullchain.pem.new" "$dest/fullchain.pem"
$ ls -l /etc/bookstore/tls | awk 'NR>1{print $1,$3,$4,$9}'
-rw-r--r-- root app fullchain.pem
-rw-r----- root app privkey.pem

The order is deliberate: key first, certificate last. The certificate is also written to a temporary file first and moved into place in one step with mv. We will see why shortly when we look at how Kestrel watches. The /etc/bookstore/tls folder is owned by root:app with 0750, so users other than app cannot enter it. Because the hook is saved to Certbot's renewal config, it runs on every renewal by itself.

Reload on Renewal Without a Restart

When Kestrel starts with this config, its Debug log says which files it watches:

dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[4]
      Created directory watcher for '/etc/bookstore/tls'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[5]
      Created file watcher for '/etc/bookstore/tls/fullchain.pem'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[4]
      Created directory watcher for '/etc/bookstore/internal'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[5]
      Created file watcher for '/etc/bookstore/internal/partner-api.fullchain.pem'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[5]
      Created file watcher for '/etc/bookstore/internal/legacy.fullchain.pem'.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://[::]:8443

Note: there is no watcher for privkey.pem. The CertificatePathWatcher in the ASP.NET Core source code watches only Path; a change to KeyPath alone does not trigger a reload. The code also deliberately forces polling. The comment in the source says polling watches both symlinks and their targets, while the normal watcher only sees the symlink itself. So even if you pointed directly at Certbot's live/ symlinks, the change would be caught; the only thing that keeps us away from there is permissions. That is where the deploy hook's ordering comes from: the certificate changes last, so the new key is already in place when Kestrel is triggered.

Now the second question. I forced Certbot to renew and watched the served serial number second by second:

$ certbot renew --force-renewal > /tmp/renew.log 2>&1; date -u +"renewed at %H:%M:%S"
$ for i in $(seq 1 12); do printf "%s " "$(date -u +%H:%M:%S)"
    echo | openssl s_client -connect api.bookstore.example:8443 -servername api.bookstore.example 2>/dev/null \
      | openssl x509 -noout -serial; sleep 1; done
renewed at 22:05:11
22:05:11 serial=26100F532DAF9882
22:05:12 serial=26100F532DAF9882
22:05:13 serial=26100F532DAF9882
22:05:14 serial=26100F532DAF9882
22:05:15 serial=26100F532DAF9882
22:05:16 serial=7FC6AF2F987DCAD7
22:05:17 serial=7FC6AF2F987DCAD7
22:05:18 serial=7FC6AF2F987DCAD7
22:05:19 serial=7FC6AF2F987DCAD7
22:05:20 serial=7FC6AF2F987DCAD7
22:05:21 serial=7FC6AF2F987DCAD7
22:05:22 serial=7FC6AF2F987DCAD7

No restart; the new certificate is served about five seconds later, and the delay comes from the polling interval. But the log shows the reload is not as quiet as one might think:

$ grep -A1 "Kestrel\[0\]" /tmp/api.log
dbug: Microsoft.AspNetCore.Server.Kestrel[0]
      Config reload token fired. Checking for changes...
info: Microsoft.AspNetCore.Server.Kestrel[0]
      Config changed. Stopping the following endpoints: 'https://*:8443'
info: Microsoft.AspNetCore.Server.Kestrel[0]
      Config changed. Starting the following endpoints: 'https://*:8443'

Kestrel treats the certificate change like a configuration change and stops and restarts the endpoint. According to Microsoft's endpoint documentation, requests on a modified endpoint get five seconds to finish, and clients trying to connect meanwhile may be refused. The answer to Guess First: no restart is needed, but a short outage window can open at the moment of renewal. On a single instance that means a few connection errors. If you expose Kestrel directly to the internet, that is why not renewing several instances at once matters.

SNI in Code: ServerCertificateSelector

If the certificates are too dynamic to write in configuration, for example when every tenant has its own domain or the certificates come from a database or Key Vault, you make the choice in code. ServerCertificateSelector is called on every connection with the name the client sent in SNI:

using Bookstore.Api;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

if (builder.Configuration.GetValue<bool>("Tls:UseSelector"))
{
    string certRoot = builder.Configuration["Tls:CertificateRoot"]!;
    CertificateCache cache = new(certRoot, TimeSpan.FromMinutes(1));

    builder.WebHost.ConfigureKestrel(kestrel =>
        kestrel.ListenAnyIP(9443, listen => listen.UseHttps(https =>
            https.ServerCertificateSelector = (_, hostName) =>
                cache.Get(hostName is not null && cache.Has(hostName) ? hostName : "api.bookstore.example"))));
}

WebApplication app = builder.Build();

app.MapGet("/api/health", (HttpContext context) => $"ok from {context.Request.Host.Host}\n");

app.Run();
using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;

namespace Bookstore.Api;

public sealed class CertificateCache(string root, TimeSpan maxAge)
{
    private readonly ConcurrentDictionary<string, (X509Certificate2 Certificate, DateTimeOffset LoadedAt)> _entries = new();

    public bool Has(string hostName) => Directory.Exists(Path.Combine(root, hostName));

    public X509Certificate2 Get(string hostName)
    {
        (X509Certificate2 Certificate, DateTimeOffset LoadedAt) entry = _entries.AddOrUpdate(
            hostName,
            Load,
            (name, existing) => DateTimeOffset.UtcNow - existing.LoadedAt < maxAge ? existing : Load(name));
        return entry.Certificate;
    }

    private (X509Certificate2, DateTimeOffset) Load(string hostName)
    {
        string dir = Path.Combine(root, hostName);
        X509Certificate2 certificate = X509Certificate2.CreateFromPemFile(
            Path.Combine(dir, "fullchain.pem"), Path.Combine(dir, "privkey.pem"));

        if (OperatingSystem.IsWindows())
        {
            // On Windows, SslStream cannot use an ephemeral key loaded from PEM; reload it through PKCS#12
            certificate = X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pkcs12), null);
        }

        return (certificate, DateTimeOffset.UtcNow);
    }
}
$ for h in api.bookstore.example partner-api.bookstore.internal unknown.bookstore.example; do
    printf "%-32s " $h
    echo | openssl s_client -connect 127.0.0.1:9443 -servername $h 2>/dev/null \
      | openssl x509 -noout -ext subjectAltName | tail -1
  done
api.bookstore.example            DNS:api.bookstore.example
partner-api.bookstore.internal   DNS:partner-api.bookstore.internal
unknown.bookstore.example        DNS:api.bookstore.example

Choosing in code moves two responsibilities to you. The first is reload: Kestrel's file watcher only works for certificates in configuration, so re-reading a renewed file in a selector is your job. The cache here does it simply with a one-minute age. The second is unknown names: if the selector throws, the handshake fails, so falling back to a default certificate is necessary. There is also a Windows detail: the key returned by CreateFromPemFile is ephemeral, and SslStream on Windows cannot use it on the server side; Microsoft's SslStream troubleshooting page links the "No credentials are available in the security package" error to this situation. The code has a branch that converts to PKCS#12 and reloads for that. This branch did not run on Linux; I did not try it on Windows. Kestrel's configuration loader does this conversion itself for PEM files.

TLS in Kestrel or in the Reverse Proxy?

The scenario in this post was TLS ending in Kestrel. In most production setups, though, TLS ends at an Nginx, YARP, cloud load balancer or Kubernetes ingress/gateway in front, and Kestrel speaks plain HTTP behind it. A short rule of thumb: if several applications run on the same machine, if you want to manage certificates in one place, or if you gather work like HTTP/3 and rate limiting in the proxy, let TLS end at the proxy. For a single application and a simple setup, Kestrel itself is enough. Behind a proxy, Kestrel needs Forwarded Headers to know the request actually arrived over HTTPS; that is the subject of the third post of the Web Servers and Hosting series. If the hop between proxy and Kestrel must also be encrypted (zero-trust network, regulation), you use an internal CA's certificate behind it and configure the proxy's equivalents of the HttpClient settings below.

HttpClient: Trusting an Internal CA Without Turning Validation Off

Now the client side. Bookstore's order service connects both to api.bookstore.example, which has a public certificate, and to the partner API, which uses the internal CA's certificate. I wrote four modes, one of which shows the dangerous option:

using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

string mode = args[0];
string url = args[1];

HttpMessageHandler handler = mode switch
{
    "default" => new SocketsHttpHandler(),
    "internal-ca" => CreateInternalCaHandler("/certs/internal-root.crt"),
    "issuer-rule" => CreateIssuerRuleHandler("/certs/internal-root.crt", "CN=Bookstore Test Issuing CA 1, O=Bookstore Test, C=TR"),
    "dangerous" => new HttpClientHandler
    {
        ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
    },
    _ => throw new ArgumentException($"unknown mode {mode}")
};

using HttpClient client = new(handler);
try
{
    string body = await client.GetStringAsync(url);
    Console.WriteLine($"[{mode}] OK: {body.Trim()}");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"[{mode}] {ex.InnerException?.Message ?? ex.Message}");
}

static SocketsHttpHandler CreateInternalCaHandler(string rootPath)
{
    X509Certificate2 root = X509CertificateLoader.LoadCertificateFromFile(rootPath);
    return new SocketsHttpHandler
    {
        SslOptions = new SslClientAuthenticationOptions
        {
            CertificateChainPolicy = new X509ChainPolicy
            {
                // trust only this root; the system trust store is not used
                TrustMode = X509ChainTrustMode.CustomRootTrust,
                CustomTrustStore = { root },
                RevocationMode = X509RevocationMode.NoCheck
            }
        }
    };
}

static SocketsHttpHandler CreateIssuerRuleHandler(string rootPath, string expectedIssuer)
{
    SocketsHttpHandler handler = CreateInternalCaHandler(rootPath);
    // normal validation must pass first, then an extra rule: only the expected intermediate
    handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
        errors == SslPolicyErrors.None
        && chain?.ChainElements.Count > 1
        && chain.ChainElements[1].Certificate.Subject == expectedIssuer;
    return handler;
}

In the lab I added Pebble's root to the container's system trust store, the counterpart of Let's Encrypt's root already being there on real systems. The internal root exists only in the /certs/internal-root.crt file:

$ dotnet Bookstore.PartnerClient.dll default https://api.bookstore.example:8443/api/health
[default] OK: ok from api.bookstore.example
$ dotnet Bookstore.PartnerClient.dll default https://partner-api.bookstore.internal:8443/api/health
[default] The remote certificate is invalid because of errors in the certificate chain: PartialChain
$ dotnet Bookstore.PartnerClient.dll internal-ca https://partner-api.bookstore.internal:8443/api/health
[internal-ca] OK: ok from partner-api.bookstore.internal
$ dotnet Bookstore.PartnerClient.dll internal-ca https://api.bookstore.example:8443/api/health
[internal-ca] The remote certificate is invalid because of errors in the certificate chain: PartialChain
$ dotnet Bookstore.PartnerClient.dll internal-ca https://orders.bookstore.internal:8443/api/health
[internal-ca] The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch
$ dotnet Bookstore.PartnerClient.dll internal-ca https://legacy.bookstore.internal:8443/api/health
[internal-ca] OK: ok from legacy.bookstore.internal
$ dotnet Bookstore.PartnerClient.dll issuer-rule https://partner-api.bookstore.internal:8443/api/health
[issuer-rule] OK: ok from partner-api.bookstore.internal
$ dotnet Bookstore.PartnerClient.dll issuer-rule https://legacy.bookstore.internal:8443/api/health
[issuer-rule] The remote certificate was rejected by the provided RemoteCertificateValidationCallback.
$ dotnet Bookstore.PartnerClient.dll dangerous https://orders.bookstore.internal:8443/api/health
[dangerous] OK: ok from orders.bookstore.internal

Let's read it line by line. The default client trusts the public certificate and not the internal one. The internal-ca mode trusts the internal root, but only that. Rejecting the public certificate is not a bug, it is exactly the intended isolation: this HttpClient will only talk to the partner. Name checking keeps working too; when the partner's certificate is presented for orders.bookstore.internal, we get RemoteCertificateNameMismatch. The issuer-rule mode rejects the legacy certificate, which chains to the same root but was issued by another intermediate (Issuing CA 2). That is the right use of a callback: keep the result of normal validation (errors == SslPolicyErrors.None) and add a narrow rule on top. HttpClientHandler.ServerCertificateCustomValidationCallback is the same layer as seen through HttpClientHandler.

The last line is the problem itself. DangerousAcceptAnyServerCertificateValidator accepted a certificate with the wrong name, and it would equally accept an expired, self-signed or attacker-generated one. That means allowing someone in the middle to read and modify the traffic. This line usually gets into production as "the internal service's certificate didn't work locally" and is forgotten behind an #if DEBUG or an environment check. The right way to trust an internal CA is the CustomRootTrust above. If you need system-wide trust (for every tool on the same machine), add the root to the operating system's store: /usr/local/share/ca-certificates and update-ca-certificates on Linux, LocalMachine\Root via Group Policy or certlm.msc on Windows.

In Production

Verify renewal from the outside. Kestrel's reload, Certbot's hook and the file permissions may each work on their own while the chain breaks somewhere. There should be a check that measures the served certificate's serial number and notAfter from the outside; we will turn it into an alert in the last post of the series.

Don't renew instances at the same time. Because the endpoint restarts during a reload, instances behind a load balancer that share the same certificate file (such as a common volume) all get a short outage in the same second. Renew in one place and roll out gradually, or end TLS at the proxy.

Mount the certificate as a secret in containers: a Secret volume in Kubernetes, a read-only bind mount in Docker. Kubernetes Secret volumes update by swapping a symlink. Since Kestrel's watcher polls and also looks at symlink targets, it is expected to catch that, but I did not try it on Kubernetes; verify from the outside on the first renewal. If you mount the Secret with subPath, updates never reach the container. Certificate automation on Kubernetes (cert-manager) is the subject of the next post.

Distribute the internal CA root as configuration. Let every service's HttpClient read the internal root from the same place, so that when the root changes (a planned job with internal CAs) updating one file is enough.

Common Pitfalls

Obsolete constructors: since .NET 9, the X509Certificate2 constructors that take a file or bytes are obsolete. The compiler warns:

$ dotnet build -v q -nologo 2>&1 | grep SYSLIB0057 | sort -u
/src/Program.cs(3,27): warning SYSLIB0057: 'X509Certificate2.X509Certificate2(string, string?)' is obsolete: 'Loading certificate data through the constructor or Import is obsolete. Use X509CertificateLoader instead to load certificates.' (https://aka.ms/dotnet-warnings/SYSLIB0057) [/src/Bookstore.CertLoad.csproj]

The old constructor looked at the content and guessed the format. Accepting some other format while expecting a PFX opened the door to unexpected behavior and vulnerabilities. Use X509CertificateLoader.LoadPkcs12FromFile or LoadCertificateFromFile; to load a PEM certificate together with its key, X509Certificate2.CreateFromPemFile is still fine.

Private key permissions on Linux: running Kestrel as root or doing chmod -R 755 /etc/letsencrypt to get rid of the UnauthorizedAccessException above is common but wrong. Give the application only its own certificate, with read access only.

Baking the certificate into the image: writing COPY certs/ /app/certs/ in a Dockerfile puts the private key into a layer of the image. Anyone with access to the registry can extract it, even if you delete it in a later layer. With short-lived certificates, rebuilding the image on every renewal makes no sense either. The certificate is mounted at runtime; it doesn't live in the image.

Changing only the key: Kestrel does not watch KeyPath. A script that replaces the key with a new file without touching the certificate triggers no reload. The old key keeps being used until a restart, and at restart the application won't start because the certificate and key don't match.

Changing a store-based config at runtime: a GitHub issue opened during the .NET 10 release candidate period and still open at the time of writing (dotnet/aspnetcore #64307) reports that changing Subject/Store-based certificate settings in appsettings.json while the application runs breaks the reload with an ArgumentNullException in CertificatePathWatcher. I did not try this scenario on Linux; if you use the store, plan a restart after changing the certificate setting.

A Small Experiment

★☆☆ Run dotnet dev-certs https --check --trust on your own machine. Find when the certificate expires, export it as PEM with --export-path, and check which names it covers with openssl x509 -ext subjectAltName.

★★☆ Add an entry for *.internal.bookstore.example to this post's SNI config and guess which certificate is served for a.internal.bookstore.example, internal.bookstore.example and a connection without SNI (-noservername for openssl s_client), then try it.

★★★ Generate a load of 50 requests per second against Kestrel during renewal (for example a curl loop or one of the tools from the upcoming Performance and Load Testing series). How many requests fail while the endpoint restarts? Repeat the experiment with TLS ending at an Nginx in front, using nginx -s reload, and compare the results.

In this post we carried the certificate to Kestrel by hand. In the next post we look at places where the platform takes over that job: Caddy's zero-config automatic HTTPS, Azure App Service managed certificates, Key Vault, Front Door, and cert-manager on Kubernetes.