Featured image of post Enabling automatic trust for self-signed certificates in containers during local development with .NET Aspire

Enabling automatic trust for self-signed certificates in containers during local development with .NET Aspire

Automate exporting and mounting self-signed certificate authorities into containers for secure HTTPS in local development using .NET Aspire's flexible application model.

HTTPS in local development can be challenging, especially when using containers. We typically use self-signed certificates during local development, and containers don’t automatically trust these certificates. As a result, many developers default to using HTTP when communicating between containers or between a container and the host machine, or worse, they disable certificate validation altogether.

In this article, we’ll explore how to enable all your containers to trust the self-signed certificates from your host machine, allowing you to upgrade your communications to HTTPS. We’ll also automate this process for .NET Aspire solutions.

A bit of context on self-signed certificates and containers

In local development, a self-signed certificate is generally used to enable HTTPS on local web servers (hosted on localhost, for example). A typical example is the development certificate created by ASP.NET Core using the dotnet dev-certs command. This certificate can only be used on the localhost domain. Another popular tool is mkcert, which is approaching 50k stars on GitHub.

What these certificates have in common is that their creation process installs a certificate into what’s called a trust certificate store. This store generally contains the public root certificates that are the origin of most certificates used on the internet, such as those from Let’s Encrypt and DigiCert.

A certificate present in this trusted store, or one that belongs to a certificate chain whose root is in the trusted store, is typically considered valid by operating systems, web browsers, and other applications that establish HTTPS connections.

These certificates are also known as CA (Certificate Authority) certificates. DigiCert, Let’s Encrypt, etc., are examples of CAs. Installing your own certificate into your trust certificate store effectively creates a CA known and trusted only by your machine.

Things get complicated when we introduce containers into the equation. A container can be considered a virtual operating system with its own file system, processes, and trust certificate store. This means containers do not share the trust certificate store of the host machine. Consequently, a self-signed certificate that is valid on the host machine won’t necessarily be valid inside a container.

Creating a localhost-compatible self-signed certificate for both containers and the host machine

For containers to communicate with services accessible from localhost on the host machine, the domain host.docker.internal for Docker or host.containers.internal for Podman is generally required. However, the default ASP.NET Core certificate only supports the localhost domain.

Therefore, we need to create a certificate compatible with these domains using mkcert. Ensure you follow the initial installation steps to install its CA, then execute the following command:

> mkcert -cert-file localhost.crt -key-file localhost.key localhost 127.0.0.1 ::1 host.docker.internal host.containers.internal

Created a new certificate valid for the following names 📜
 - "localhost"
 - "127.0.0.1"
 - "::1"
 - "host.docker.internal"
 - "host.containers.internal"

The certificate is at "localhost.crt" and the key at "localhost.key" ✅

It will expire on 30 January 2027 🗓️

Next, follow the instructions in the ASP.NET Core documentation to use this certificate in your services. Don’t forget to mount the certificate files into your containers.

Overriding the trusted certificates of a Linux-based container

It’s possible to override the trust certificate store of a Linux-based container so that the container can trust additional certificates.

On Linux, the trust certificate store is represented as a file containing multiple CA certificates concatenated one after another in PEM format. Each Linux distribution comes with this file, which is regularly updated. This file, usually with a .crt or .pem extension, is known as a CA bundle and looks something like this:

-----BEGIN CERTIFICATE-----
<base64 certificate data>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<base64 certificate data>
-----END CERTIFICATE-----
[...]

The location of this file varies depending on the Linux distribution, but fortunately, these locations are well known:

/etc/ssl/certs/ca-certificates.crt                // Debian/Ubuntu/Gentoo etc.
/etc/pki/tls/certs/ca-bundle.crt                  // Fedora/RHEL 6
/etc/ssl/ca-bundle.pem                            // OpenSUSE
/etc/pki/tls/cacert.pem                           // OpenELEC
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem // CentOS/RHEL 7
/etc/ssl/cert.pem                                 // Alpine Linux

Exporting a Linux-compatible CA bundle from the host machine in C#

We can use .NET’s types X509Store and X509Certificate2 from the System.Security.Cryptography.X509Certificates namespace to export the host machine’s CA bundle to a file. Let’s write a cross-platform method that does this in C#:

private static async Task ExportCertificateAuthorityBundleAsync(string path, CancellationToken cancellationToken)
{
    var visitedCertificatesThumbprints = new HashSet<string>(StringComparer.Ordinal);
    var certificateAuthorityBundleContent = new StringBuilder();

    PopulateCertificateAuthorityBundleFromStore(StoreName.Root, StoreLocation.LocalMachine);
    PopulateCertificateAuthorityBundleFromStore(StoreName.Root, StoreLocation.CurrentUser);
    PopulateCertificateAuthorityBundleFromStore(StoreName.My, StoreLocation.LocalMachine);
    PopulateCertificateAuthorityBundleFromStore(StoreName.My, StoreLocation.CurrentUser);

    await File.WriteAllTextAsync(path, certificateAuthorityBundleContent.ToString(), cancellationToken);

    void PopulateCertificateAuthorityBundleFromStore(StoreName storeName, StoreLocation storeLocation)
    {
        using var store = new X509Store(storeName, storeLocation);

        try
        {
            store.Open(OpenFlags.ReadOnly);
        }
        catch
        {
            // Not all .NET store names are accessible on all platforms
            return;
        }

        foreach (var certificate in store.Certificates)
        {
            cancellationToken.ThrowIfCancellationRequested();

            try
            {
                if (visitedCertificatesThumbprints.Add(certificate.Thumbprint))
                {
                    certificateAuthorityBundleContent.Append(certificate.ExportCertificatePem());
                    certificateAuthorityBundleContent.Append('\n');
                }
            }
            catch (CryptographicException)
            {
                // The certificate is corrupt, in an invalid state, or could not be exported to PEM
            }
        }
    }
}

This method exports certificates from multiple stores because, depending on the operating system, some certificates are stored for the current user or for the machine. On Windows, processes running as administrator will look for certificates in the machine store, while unprivileged processes will look in the current user’s store.

In the case of mkcert, its CA certificate is located:

  • In the root store for the current user on Windows,
  • In the root store for the machine on Ubuntu,
  • In the personal store for the machine on macOS.

In enterprise environments, it is common for additional certificates to be installed in personal certificate stores. The method exports them all, trying to replicate the host’s behavior as closely as possible in the containers.

Automatically binding the CA bundle to all containers orchestrated by .NET Aspire

Now that we know how to export a CA bundle and where to place it in a container, we can automate this process so that all containers orchestrated by .NET Aspire can trust the host’s self-signed certificates, thus enabling end-to-end HTTPS communication.

To do this, we just need to write an extension method WithHostCertificateAuthorityBundle for IResourceBuilder<ContainerResource> that will ensure the CA bundle is generated and mounted into the targeted container:

internal static class ContainerResourceBuilderExtensions
{
    public static IResourceBuilder<ContainerResource> WithHostCertificateAuthorityBundle(this IResourceBuilder<ContainerResource> builder)
    {
        builder.ApplicationBuilder.Services.TryAddLifecycleHook<HostCertificateAuthorityBundleLifecycleHook>();
        return builder.WithAnnotation<HostCertificateAuthorityBundleAnnotation>(ResourceAnnotationMutationBehavior.Replace);
    }

    private sealed class HostCertificateAuthorityBundleAnnotation : IResourceAnnotation;

    private sealed class HostCertificateAuthorityBundleLifecycleHook : IDistributedApplicationLifecycleHook, IDisposable
    {
        private readonly string _generatedCertificateAuthorityBundlePath = Path.Combine(Path.GetTempPath(), $"aspire-ca-bundle-{Guid.NewGuid().ToString()[..8]}.crt");

        public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
        {
            await ExportCertificateAuthorityBundleAsync(this._generatedCertificateAuthorityBundlePath, cancellationToken);

            string[] wellKnownLinuxCertificateAuthorityBundlePaths =
            [
                // Copied from https://github.com/golang/go/blob/go1.23.2/src/crypto/x509/root_linux.go#L11-L16
                "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo etc.
                "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL 6
                "/etc/ssl/ca-bundle.pem", // OpenSUSE
                "/etc/pki/tls/cacert.pem", // OpenELEC
                "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7
                "/etc/ssl/cert.pem", // Alpine Linux
            ];

            foreach (var containerResource in appModel.Resources.OfType<ContainerResource>())
            {
                if (!containerResource.Annotations.OfType<HostCertificateAuthorityBundleAnnotation>().Any())
                {
                    continue;
                }

                foreach (var containerPath in wellKnownLinuxCertificateAuthorityBundlePaths)
                {
                    containerResource.Annotations.Add(new ContainerMountAnnotation(this._generatedCertificateAuthorityBundlePath, target: containerPath, ContainerMountType.BindMount, isReadOnly: true));
                }
            }
        }

        private static async Task ExportCertificateAuthorityBundleAsync(string path, CancellationToken cancellationToken)
        {
            // Implementation from above
        }

        public void Dispose()
        {
            try
            {
                File.Delete(this._generatedCertificateAuthorityBundlePath);
            }
            catch
            {
                // Ignored
            }
        }
    }
}

If we use this method on a container, we can see that the bind mounts are added for the different CA bundle locations on Linux in Docker Desktop:

builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/samples", "aspnetapp")
    .WithHostCertificateAuthorityBundle();

We have mounted the generated CA bundle on all the well-known CA paths for Linux

A container that now wants to make an HTTPS call to a service hosted on the host machine can do so without any untrusted certificate issues. Note that if you are using .NET Aspire 9’s persistent containers, you may want to avoid deleting the generated CA bundle file in the Dispose method.

Conclusion

By exporting and mounting the host’s CA bundle into your containers, you enable secure HTTPS communication between containers and the host during local development using self-signed certificates, such as those from ASP.NET Core or mkcert. With .NET Aspire’s flexible application model, you can easily automate this process with just a few lines of code.

Licensed under CC BY 4.0
Ko-fi donations Buy me a coffee