Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4611271
Initial plan
Copilot Nov 30, 2025
41ca003
Add Aspire.Hosting.LetsEncrypt integration with Certbot container sup…
Copilot Nov 30, 2025
ac3b0f7
Pin certbot container image to stable version v5.1.0
Copilot Nov 30, 2025
3d6ccad
Add CertificatePath and PrivateKeyPath properties to CertbotResource
Copilot Nov 30, 2025
87bb3cd
Rename Aspire.Hosting.LetsEncrypt to Aspire.Hosting.Certbot and WithC…
Copilot Dec 2, 2025
67cbe2d
Refactor Certbot API: make HTTP endpoint opt-in, split args into With…
Copilot Dec 5, 2025
793c0ae
Refactor args composition: centralize in AddCertbot WithArgs callback…
Copilot Dec 5, 2025
ad1b0c7
Add WaitForCompletion to usage examples for resources that depend on …
Copilot Dec 5, 2025
1a7c32d
Add WithCertbotCertificate method that combines volume mount and Wait…
Copilot Dec 5, 2025
a5aac1d
Update method name from WithServerAuthenticationCertificateConfigurat…
Copilot Dec 5, 2025
b2f5493
Merge remote-tracking branch 'origin/main' into copilot/add-lets-encr…
Copilot Dec 5, 2025
e6bba87
Update documentation to reference correct HTTPS certificate APIs and …
Copilot Dec 5, 2025
2b408c0
Remove WithCertificateVolume method and related tests
Copilot Dec 5, 2025
9cd0d37
Update src/Aspire.Hosting.Certbot/Aspire.Hosting.Certbot.csproj
davidfowl Dec 5, 2025
e7b75bc
Simplify README: remove verbose usage explanation and Connection Prop…
Copilot Dec 6, 2025
ed7ec9e
Simplify README further: remove Configuration section and focus on mi…
Copilot Dec 6, 2025
44d566d
Fix markdown linting: remove multiple consecutive blank lines
Copilot Dec 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<Project Path="src/Aspire.Hosting.Kafka/Aspire.Hosting.Kafka.csproj" />
<Project Path="src/Aspire.Hosting.Keycloak/Aspire.Hosting.Keycloak.csproj" />
<Project Path="src/Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.csproj" />
<Project Path="src/Aspire.Hosting.Certbot/Aspire.Hosting.Certbot.csproj" />
<Project Path="src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj" />
<Project Path="src/Aspire.Hosting.Milvus/Aspire.Hosting.Milvus.csproj" />
<Project Path="src/Aspire.Hosting.MongoDB/Aspire.Hosting.MongoDB.csproj" />
Expand Down Expand Up @@ -421,6 +422,7 @@
<Project Path="tests/Aspire.Hosting.Kafka.Tests/Aspire.Hosting.Kafka.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Keycloak.Tests/Aspire.Hosting.Keycloak.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Kubernetes.Tests/Aspire.Hosting.Kubernetes.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Certbot.Tests/Aspire.Hosting.Certbot.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Milvus.Tests/Aspire.Hosting.Milvus.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.MongoDB.Tests/Aspire.Hosting.MongoDB.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.MySql.Tests/Aspire.Hosting.MySql.Tests.csproj" />
Expand Down
21 changes: 21 additions & 0 deletions src/Aspire.Hosting.Certbot/Aspire.Hosting.Certbot.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<IsPackable>true</IsPackable>
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
<PackageTags>aspire integration hosting certbot letsencrypt ssl tls https acme</PackageTags>
<Description>Certbot support for Aspire.</Description>
<!-- Disable package validation for new packages without a baseline version -->
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Aspire.Hosting.Certbot.Tests"/>
</ItemGroup>

</Project>
178 changes: 178 additions & 0 deletions src/Aspire.Hosting.Certbot/CertbotBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

/// <summary>
/// Provides extension methods for adding Certbot resources to the application model.
/// </summary>
public static class CertbotBuilderExtensions
{
/// <summary>
/// Adds a Certbot container to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="domain">The parameter containing the domain name to obtain a certificate for.</param>
/// <param name="email">The parameter containing the email address for certificate registration.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method adds a Certbot container that obtains SSL/TLS certificates using the ACME protocol.
/// By default, no challenge method is configured. Use <see cref="WithHttp01Challenge"/> or other challenge methods to configure how certificates are obtained.
/// </para>
/// <para>
/// The certificates are stored in a shared volume named "letsencrypt" at /etc/letsencrypt.
/// Other resources can mount this volume to access the certificates.
/// </para>
/// <para>
/// Certificate permissions are automatically set to allow non-root containers to read them.
/// </para>
/// This version of the package defaults to the <inheritdoc cref="CertbotContainerImageTags.Tag"/> tag of the <inheritdoc cref="CertbotContainerImageTags.Image"/> container image.
/// <example>
/// Use in application host:
/// <code lang="csharp">
/// var domain = builder.AddParameter("domain");
/// var email = builder.AddParameter("email");
///
/// var certbot = builder.AddCertbot("certbot", domain, email)
/// .WithHttp01Challenge();
///
/// var myService = builder.AddContainer("myservice", "myimage")
/// .WithCertbotCertificate(certbot);
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<CertbotResource> AddCertbot(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
IResourceBuilder<ParameterResource> domain,
IResourceBuilder<ParameterResource> email)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(domain);
ArgumentNullException.ThrowIfNull(email);

var resource = new CertbotResource(name, domain.Resource, email.Resource);

return builder.AddResource(resource)
.WithImage(CertbotContainerImageTags.Image, CertbotContainerImageTags.Tag)
.WithImageRegistry(CertbotContainerImageTags.Registry)
.WithVolume(CertbotResource.CertificatesVolumeName, CertbotResource.CertificatesPath)
.WithArgs(context =>
{
var certbotResource = (CertbotResource)context.Resource;

// Only add args if a challenge method is configured
if (certbotResource.ChallengeMethod is null)
{
return;
}

// Common arguments for all challenge methods
context.Args.Add("certonly");
context.Args.Add("--non-interactive");
context.Args.Add("--agree-tos");
context.Args.Add("-v");
context.Args.Add("--keep-until-expiring");

// Challenge-specific arguments
switch (certbotResource.ChallengeMethod)
{
case CertbotChallengeMethod.Http01:
context.Args.Add("--standalone");
break;
}

// Always set permissions to allow non-root containers to read certificates
context.Args.Add("--deploy-hook");
context.Args.Add("chmod -R 755 /etc/letsencrypt/live && chmod -R 755 /etc/letsencrypt/archive");

// Email and domain arguments
context.Args.Add("--email");
context.Args.Add(certbotResource.EmailParameter);
context.Args.Add("-d");
context.Args.Add(certbotResource.DomainParameter);
});
}

/// <summary>
/// Configures Certbot to use the HTTP-01 challenge for domain validation.
/// </summary>
/// <param name="builder">The Certbot resource builder.</param>
/// <param name="port">The host port to publish for the HTTP-01 challenge. Defaults to 80.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// The HTTP-01 challenge requires Certbot to be accessible on port 80 from the internet.
/// This method configures the container to listen on the specified port and sets up
/// the standalone mode for ACME challenge validation.
/// </para>
/// <example>
/// <code lang="csharp">
/// var certbot = builder.AddCertbot("certbot", domain, email)
/// .WithHttp01Challenge(port: 8080);
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<CertbotResource> WithHttp01Challenge(
this IResourceBuilder<CertbotResource> builder,
int? port = 80)
{
ArgumentNullException.ThrowIfNull(builder);

builder.Resource.ChallengeMethod = CertbotChallengeMethod.Http01;

return builder
.WithHttpEndpoint(port: port, targetPort: 80, name: CertbotResource.HttpEndpointName)
.WithExternalHttpEndpoints();
}

/// <summary>
/// Configures the container to use SSL/TLS certificates from a Certbot resource.
/// </summary>
/// <typeparam name="T">The type of the container resource.</typeparam>
/// <param name="builder">The resource builder for the container resource that needs access to the certificates.</param>
/// <param name="certbot">The Certbot resource builder.</param>
/// <param name="mountPath">The path where the certificates volume should be mounted. Defaults to /etc/letsencrypt.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method mounts the certificates volume and ensures the container waits for the Certbot
/// resource to complete certificate acquisition before starting.
/// </para>
/// <para>
/// <strong>Note:</strong> Do not use this method together with <c>WithHttpsCertificate</c>
/// or <c>WithHttpsCertificateConfiguration</c>
/// at runtime, as they will conflict. However, you can use Certbot in publish mode while using the other methods in development mode
/// by wrapping the Certbot configuration in an <c>ExecutionContext.IsPublishMode</c> check.
/// </para>
/// <example>
/// <code lang="csharp">
/// var domain = builder.AddParameter("domain");
/// var email = builder.AddParameter("email");
///
/// var certbot = builder.AddCertbot("certbot", domain, email)
/// .WithHttp01Challenge();
///
/// var yarp = builder.AddContainer("yarp", "myimage")
/// .WithCertbotCertificate(certbot);
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<T> WithCertbotCertificate<T>(
this IResourceBuilder<T> builder,
IResourceBuilder<CertbotResource> certbot,
string mountPath = CertbotResource.CertificatesPath) where T : ContainerResource, IResourceWithWaitSupport
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(certbot);

return builder
.WithVolume(CertbotResource.CertificatesVolumeName, mountPath)
.WaitForCompletion(certbot);
}
}
16 changes: 16 additions & 0 deletions src/Aspire.Hosting.Certbot/CertbotContainerImageTags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting;

internal static class CertbotContainerImageTags
{
/// <remarks>docker.io</remarks>
public const string Registry = "docker.io";

/// <remarks>certbot/certbot</remarks>
public const string Image = "certbot/certbot";

/// <remarks>v5.1.0</remarks>
public const string Tag = "v5.1.0";
}
72 changes: 72 additions & 0 deletions src/Aspire.Hosting.Certbot/CertbotResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents a Certbot container resource for obtaining and renewing SSL/TLS certificates.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="domain">A parameter containing the domain name to obtain a certificate for.</param>
/// <param name="email">A parameter containing the email address for certificate registration and notifications.</param>
public class CertbotResource(string name, ParameterResource domain, ParameterResource email) : ContainerResource(name)
{
internal const string HttpEndpointName = "http";
internal const string CertificatesVolumeName = "letsencrypt";
internal const string CertificatesPath = "/etc/letsencrypt";

private EndpointReference? _httpEndpoint;

/// <summary>
/// Gets the HTTP endpoint for the Certbot ACME challenge server.
/// </summary>
public EndpointReference HttpEndpoint => _httpEndpoint ??= new(this, HttpEndpointName);

/// <summary>
/// Gets the parameter that contains the domain name.
/// </summary>
public ParameterResource DomainParameter { get; } = domain ?? throw new ArgumentNullException(nameof(domain));

/// <summary>
/// Gets the parameter that contains the email address for certificate registration.
/// </summary>
public ParameterResource EmailParameter { get; } = email ?? throw new ArgumentNullException(nameof(email));

/// <summary>
/// Gets or sets the challenge method to use for domain validation.
/// </summary>
internal CertbotChallengeMethod? ChallengeMethod { get; set; }

/// <summary>
/// Gets an expression representing the path to the SSL/TLS certificate (fullchain.pem) for the domain.
/// </summary>
/// <remarks>
/// The certificate path follows the standard convention: <c>/etc/letsencrypt/live/{domain}/fullchain.pem</c>.
/// This property returns a <see cref="ReferenceExpression"/> that resolves to the actual path at runtime
/// based on the domain parameter value.
/// </remarks>
public ReferenceExpression CertificatePath =>
ReferenceExpression.Create($"{CertificatesPath}/live/{DomainParameter}/fullchain.pem");

/// <summary>
/// Gets an expression representing the path to the private key (privkey.pem) for the domain.
/// </summary>
/// <remarks>
/// The private key path follows the standard convention: <c>/etc/letsencrypt/live/{domain}/privkey.pem</c>.
/// This property returns a <see cref="ReferenceExpression"/> that resolves to the actual path at runtime
/// based on the domain parameter value.
/// </remarks>
public ReferenceExpression PrivateKeyPath =>
ReferenceExpression.Create($"{CertificatesPath}/live/{DomainParameter}/privkey.pem");
}

/// <summary>
/// Represents the ACME challenge method to use for domain validation.
/// </summary>
internal enum CertbotChallengeMethod
{
/// <summary>
/// HTTP-01 challenge using standalone mode.
/// </summary>
Http01
}
48 changes: 48 additions & 0 deletions src/Aspire.Hosting.Certbot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Aspire.Hosting.Certbot library

Provides extension methods and resource definitions for an Aspire AppHost to configure a Certbot resource.

## Getting started

### Install the package

In your AppHost project, install the Aspire Certbot Hosting library with [NuGet](https://www.nuget.org):

```dotnetcli
dotnet add package Aspire.Hosting.Certbot
```

## Usage example

Then, in the _AppHost.cs_ file of `AppHost`, add a Certbot resource and consume the certificates:

```csharp
var domain = builder.AddParameter("domain");
var email = builder.AddParameter("email");

var certbot = builder.AddCertbot("certbot", domain, email)
.WithHttp01Challenge();

var myService = builder.AddContainer("myservice", "myimage")
.WithCertbotCertificate(certbot);
```

## Certificate Locations

Certificates obtained by Certbot are stored at:

| Path | Description |
|------|-------------|
| `/etc/letsencrypt/live/{domain}/fullchain.pem` | The full certificate chain |
| `/etc/letsencrypt/live/{domain}/privkey.pem` | The private key |

These paths are accessible via the `CertbotResource.CertificatePath` and `CertbotResource.PrivateKeyPath` properties.

## Additional documentation

* https://certbot.eff.org/docs/
* https://letsencrypt.org/docs/

## Feedback & contributing

https://github.com/dotnet/aspire
Loading
Loading