diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index fef4d632..1990d7d2 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -59,6 +59,7 @@ jobs:
Hosting.Sqlite.Tests,
Hosting.SqlServer.Extensions.Tests,
Hosting.SurrealDb.Tests,
+ Hosting.Zitadel.Tests,
# Client integration tests
GoFeatureFlag.Tests,
diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a66c47b9..ad184232 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -162,6 +162,9 @@
+
+
+
@@ -199,6 +202,7 @@
+
@@ -251,6 +255,7 @@
+
diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/AppHost.cs b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/AppHost.cs
new file mode 100644
index 00000000..4f89588c
--- /dev/null
+++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/AppHost.cs
@@ -0,0 +1,8 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var database = builder.AddPostgres("postgres");
+
+builder.AddZitadel("zitadel")
+ .WithDatabase(database);
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost.csproj b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost.csproj
new file mode 100644
index 00000000..a13fcb3e
--- /dev/null
+++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost.csproj
@@ -0,0 +1,12 @@
+
+
+
+ Exe
+ 981579ce-9426-4ad6-b6f1-192f3f4cf73b
+
+
+
+
+
+
+
diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/Properties/launchSettings.json b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..e0366292
--- /dev/null
+++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17100;http://localhost:15025",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21182",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23175",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22260"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15025",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19275",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18212",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20294"
+ }
+ }
+ }
+}
diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/appsettings.json b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/appsettings.json
new file mode 100644
index 00000000..31c092aa
--- /dev/null
+++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.csproj b/src/CommunityToolkit.Aspire.Hosting.Zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.csproj
new file mode 100644
index 00000000..230ce099
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.csproj
@@ -0,0 +1,13 @@
+
+
+
+ A .NET Aspire host integration for Zitadel.
+ zitadel auth identity oauth2 authenticatioon openid-connect oidc
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/README.md b/src/CommunityToolkit.Aspire.Hosting.Zitadel/README.md
new file mode 100644
index 00000000..bf22453f
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/README.md
@@ -0,0 +1,65 @@
+# CommunityToolkit.Aspire.Hosting.Zitadel library
+
+Provides extension methods and resource definitions for a .NET Aspire AppHost to configure Zitadel.
+
+## Getting Started
+
+### Install the package
+
+In your AppHost project, install the package using the following command:
+
+```dotnetcli
+dotnet add package CommunityToolkit.Aspire.Hosting.Zitadel
+```
+
+### Example usage
+
+Then, in the _Program.cs_ file of `AppHost`, define a Zitadel resource, then call `AddZitadel`:
+
+```csharp
+builder.AddZitadel("zitadel");
+```
+
+Zitadel *requires* a Postgres database, you can add one with `WithDatabase`:
+```csharp
+var database = builder.AddPostgres("postgres");
+
+builder.AddZitadel("zitadel")
+ .WithDatabase(database);
+```
+You can also pass in a database rather than server (`AddPostgres().AddDatabase()`).
+
+### Configuring the External Domain
+
+By default, Zitadel uses `{name}.dev.localhost` as the external domain, which works well for local development. For production deployments or custom scenarios, you can configure a custom external domain:
+
+**Option 1: Using the parameter**
+```csharp
+builder.AddZitadel("zitadel", externalDomain: "auth.example.com");
+```
+
+**Option 2: Using the fluent API**
+```csharp
+builder.AddZitadel("zitadel")
+ .WithExternalDomain("auth.example.com");
+```
+
+**Option 3: From configuration**
+```csharp
+var domain = builder.Configuration["Zitadel:ExternalDomain"];
+builder.AddZitadel("zitadel", externalDomain: domain);
+```
+
+#### Why `.dev.localhost`?
+
+`.dev.localhost` is a special top-level domain that:
+- Automatically resolves to `127.0.0.1` without requiring DNS configuration
+- Provides unique subdomains for each Zitadel instance (e.g., `zitadel1.dev.localhost`, `zitadel2.dev.localhost`)
+- Works reliably in local development and CI/CD environments
+- Satisfies Zitadel's requirement for stable hostnames in OIDC/OAuth2 flows
+
+For production deployments, replace this with your actual domain name using one of the configuration methods above.
+
+## Feedback & contributing
+
+https://github.com/CommunityToolkit/Aspire
diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelContainerImageTags.cs
new file mode 100644
index 00000000..2026305d
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelContainerImageTags.cs
@@ -0,0 +1,13 @@
+namespace CommunityToolkit.Aspire.Hosting.Zitadel;
+
+internal static class ZitadelContainerImageTags
+{
+ /// Github Container Registry
+ public const string Registry = "ghcr.io";
+
+ /// zitadel/zitadel
+ public const string Image = "zitadel/zitadel";
+
+ /// v4.7.0
+ public const string Tag = "v4.7.0";
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelHostingExtensions.cs
new file mode 100644
index 00000000..aef826ef
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelHostingExtensions.cs
@@ -0,0 +1,141 @@
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.Zitadel;
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for adding Zitadel to an .
+///
+public static class ZitadelHostingExtensions
+{
+ ///
+ /// Adds a Zitadel container resource to the .
+ ///
+ /// The to add the Zitadel container to.
+ /// The name of the resource. This name will be used as the connection string name when referenced in a dependency.
+ /// The host port used when launching the container. If null a random port will be assigned
+ /// An optional parameter to set a username for the admin account, if null will auto generate one.
+ /// An optional parameter to set a password for the admin account, if null will auto generate one.
+ /// An optional parameter to set the masterkey, if null will auto generate one.
+ public static IResourceBuilder AddZitadel(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name,
+ int? port = null,
+ IResourceBuilder? username = null,
+ IResourceBuilder? password = null,
+ IResourceBuilder? masterKey = null
+ )
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(name);
+
+ var usernameParameter = username?.Resource ?? new ParameterResource($"{name}-username", _ => "admin", false);
+ var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password", minSpecial: 1);
+ var masterKeyParameter = masterKey?.Resource ?? ParameterResourceBuilderExtensions.CreateGeneratedParameter(builder, $"{name}-masterKey", true, new GenerateParameterDefault
+ {
+ MinLength = 32, // Zitadel requires 32, CreateDefaultPasswordParameter generates 22
+ Lower = true,
+ Upper = true,
+ Numeric = true,
+ Special = true,
+ MinLower = 1,
+ MinUpper = 1,
+ MinNumeric = 1,
+ MinSpecial = 1
+ });
+
+ var resource = new ZitadelResource(name)
+ {
+ AdminUsernameParameter = usernameParameter,
+ AdminPasswordParameter = passwordParameter
+ };
+
+ var zitadelBuilder = builder.AddResource(resource)
+ .WithImage(ZitadelContainerImageTags.Image)
+ .WithImageTag(ZitadelContainerImageTags.Tag)
+ .WithImageRegistry(ZitadelContainerImageTags.Registry)
+ .WithArgs("start-from-init", "--masterkeyFromEnv")
+ .WithHttpEndpoint(
+ targetPort: 8080,
+ port: port,
+ name: ZitadelResource.HttpEndpointName
+ )
+ .WithHttpHealthCheck("/healthz")
+ .WithEnvironment("ZITADEL_MASTERKEY", masterKeyParameter)
+ .WithEnvironment("ZITADEL_TLS_ENABLED", "false")
+ .WithEnvironment("ZITADEL_EXTERNALSECURE", "false")
+ .WithUrlForEndpoint(ZitadelResource.HttpEndpointName, e => e.DisplayText = "Zitadel Dashboard");
+
+ // Use ReferenceExpression for the port to avoid issues with endpoint allocation
+ var endpoint = resource.GetEndpoint(ZitadelResource.HttpEndpointName);
+ var portExpression = ReferenceExpression.Create($"{endpoint.Property(EndpointProperty.Port)}");
+ var hostExpression = ReferenceExpression.Create($"{endpoint.Property(EndpointProperty.Host)}");
+
+ return zitadelBuilder
+ .WithEnvironment("ZITADEL_EXTERNALDOMAIN", hostExpression)
+ .WithEnvironment("ZITADEL_EXTERNALPORT", portExpression)
+ // Disable Login V2 for simpler setup (no separate login container needed)
+ .WithEnvironment("ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED", "false")
+ // Configure admin user
+ .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME", usernameParameter)
+ .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD", passwordParameter)
+ .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED", "false");
+ }
+
+ ///
+ /// Adds database support to the Zitadel resource.
+ ///
+ /// The Zitadel resource to add database support to.
+ /// The Postgres server resource to use for the database.
+ /// An optional name for the database Zitadel will use, if left empty will default to "zitadel-db".
+ public static IResourceBuilder WithDatabase(
+ this IResourceBuilder builder,
+ IResourceBuilder server,
+ [ResourceName] string? databaseName = null
+ )
+ {
+ databaseName = string.IsNullOrWhiteSpace(databaseName) ? "zitadel-db" : databaseName;
+ var database = server.AddDatabase(databaseName);
+
+ return WithDatabase(builder, database);
+ }
+
+ ///
+ /// Adds database support to the Zitadel resource.
+ ///
+ /// The Zitadel resource to add database support to.
+ /// The Postgres database resource to use for the database.
+ public static IResourceBuilder WithDatabase(this IResourceBuilder builder, IResourceBuilder database)
+ {
+ ArgumentNullException.ThrowIfNull(database);
+
+ builder
+ .WithEnvironment("ZITADEL_DATABASE_POSTGRES_USER_USERNAME", database.Resource.Parent.UserNameReference)
+ .WithEnvironment("ZITADEL_DATABASE_POSTGRES_USER_PASSWORD", database.Resource.Parent.PasswordParameter)
+ .WithEnvironment("ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME", database.Resource.Parent.UserNameReference)
+ .WithEnvironment("ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD", database.Resource.Parent.PasswordParameter)
+ .WithEnvironment("ZITADEL_DATABASE_POSTGRES_HOST", database.Resource.Parent.Host)
+ .WithEnvironment("ZITADEL_DATABASE_POSTGRES_PORT", database.Resource.Parent.Port)
+ .WithEnvironment("ZITADEL_DATABASE_POSTGRES_DATABASE", database.Resource.DatabaseName)
+ .WithReference(database)
+ .WaitFor(database);
+
+ return builder;
+ }
+
+ ///
+ /// Configures the external domain for the Zitadel resource. This overrides the default domain set in .
+ ///
+ /// The Zitadel resource builder.
+ /// The external domain to use (e.g., "auth.example.com"). Cannot be null or empty.
+ /// The resource builder for chaining.
+ /// Thrown if is null or whitespace.
+ public static IResourceBuilder WithExternalDomain(
+ this IResourceBuilder builder,
+ string externalDomain)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(externalDomain);
+
+ return builder.WithEnvironment("ZITADEL_EXTERNALDOMAIN", externalDomain);
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelResource.cs b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelResource.cs
new file mode 100644
index 00000000..3904eb55
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelResource.cs
@@ -0,0 +1,21 @@
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Zitadel;
+
+///
+/// Resource for the Zitadel API server.
+///
+public sealed class ZitadelResource(string name) : ContainerResource(name)
+{
+ internal const string HttpEndpointName = "http";
+
+ ///
+ /// The parameter that contains the (default) Zitadel admin username.
+ ///
+ public required ParameterResource AdminUsernameParameter { get; set; }
+
+ ///
+ /// The parameter that contains the (default) Zitadel admin password.
+ ///
+ public required ParameterResource AdminPasswordParameter { get; set; }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/AppHostTests.cs
new file mode 100644
index 00000000..e77e178a
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/AppHostTests.cs
@@ -0,0 +1,99 @@
+using CommunityToolkit.Aspire.Testing;
+using Aspire.Components.Common.Tests;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests;
+
+[RequiresDocker]
+public class AppHostTests(
+ AspireIntegrationTestFixture fixture
+) : IClassFixture>
+{
+ [Fact]
+ public async Task Zitadel_Starts_And_Responds_Ok()
+ {
+ var resourceName = "zitadel";
+
+ // Wait for Zitadel to be healthy (it has a health check configured)
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(resourceName)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ var httpClient = fixture.CreateHttpClient(resourceName);
+
+ // Test the health endpoint
+ var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openid-configuration");
+ // Needs to match the external domain for Zitadel or we get a 404
+ request.Headers.Host = $"{resourceName}.dev.localhost";
+ var response = await httpClient.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Zitadel_Starts_And_Serves_Dashboard()
+ {
+ var resourceName = "zitadel";
+
+ // Wait for Zitadel to be healthy (it has a health check configured)
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(resourceName)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ var httpClient = fixture.CreateHttpClient(resourceName);
+
+ // Test the health endpoint
+ var request = new HttpRequestMessage(HttpMethod.Get, "/");
+ // Needs to match the external domain for Zitadel or we get a 404
+ request.Headers.Host = $"{resourceName}.dev.localhost";
+ var response = await httpClient.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.Contains("
+
+
+
+
+
+
+
+
+
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelHostingExtensionsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelHostingExtensionsTests.cs
new file mode 100644
index 00000000..c41337c9
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelHostingExtensionsTests.cs
@@ -0,0 +1,202 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.Zitadel;
+
+namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests;
+
+public class ZitadelHostingExtensionsTests
+{
+ [Fact]
+ public void AddZitadel_Should_Throw_If_Builder_Is_Null()
+ {
+ IDistributedApplicationBuilder builder = null!;
+
+ var act = () => builder.AddZitadel("zitadel");
+
+ var exception = Assert.Throws(act);
+ Assert.Equal("builder", exception.ParamName);
+ }
+
+ [Fact]
+ public void AddZitadel_Should_Throw_If_Name_Is_Null()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var act = () => builder.AddZitadel(null!);
+
+ var exception = Assert.Throws(act);
+ Assert.Equal("name", exception.ParamName);
+ }
+
+ [Fact]
+ public void AddZitadel_Creates_ZitadelResource()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var zitadel = builder.AddZitadel("zitadel");
+
+ Assert.NotNull(zitadel);
+ Assert.IsType(zitadel.Resource);
+ Assert.Equal("zitadel", zitadel.Resource.Name);
+ }
+
+ [Fact]
+ public async Task AddZitadel_Sets_Default_Environment_Variables()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var zitadel = builder.AddZitadel("zitadel");
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.Equal("false", env["ZITADEL_TLS_ENABLED"]);
+ Assert.Equal("false", env["ZITADEL_EXTERNALSECURE"]);
+ Assert.Equal("zitadel.dev.localhost", env["ZITADEL_EXTERNALDOMAIN"]);
+ Assert.Equal("false", env["ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED"]);
+ Assert.Equal("false", env["ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED"]);
+ }
+
+ [Fact]
+ public async Task AddZitadel_Sets_Admin_Username_And_Password()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var zitadel = builder.AddZitadel("zitadel");
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.NotNull(zitadel.Resource.AdminUsernameParameter);
+ Assert.NotNull(zitadel.Resource.AdminPasswordParameter);
+ Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME"));
+ Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD"));
+ }
+
+ [Fact]
+ public void AddZitadel_Uses_Custom_Username_And_Password()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var username = builder.AddParameter("custom-username");
+ var password = builder.AddParameter("custom-password");
+
+ var zitadel = builder.AddZitadel("zitadel", username: username, password: password);
+
+ Assert.Same(username.Resource, zitadel.Resource.AdminUsernameParameter);
+ Assert.Same(password.Resource, zitadel.Resource.AdminPasswordParameter);
+ }
+
+ [Fact]
+ public async Task AddZitadel_Uses_Custom_MasterKey()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var masterKey = builder.AddParameter("custom-masterkey");
+
+ var zitadel = builder.AddZitadel("zitadel", masterKey: masterKey);
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.True(env.ContainsKey("ZITADEL_MASTERKEY"));
+ }
+
+ [Fact]
+ public void AddZitadel_Has_HttpEndpoint()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var zitadel = builder.AddZitadel("zitadel");
+
+ var endpoint = zitadel.Resource.Annotations.OfType()
+ .FirstOrDefault(e => e.Name == "http");
+
+ Assert.NotNull(endpoint);
+ }
+
+ [Fact]
+ public void AddZitadel_With_Custom_Port()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddZitadel("zitadel", port: 8888);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ var endpoint = resource.Annotations.OfType()
+ .First(e => e.Name == "http");
+
+ Assert.Equal(8888, endpoint.Port);
+ }
+
+ [Fact]
+ public async Task AddZitadel_Uses_Custom_ExternalDomain()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var zitadel = builder.AddZitadel("zitadel", externalDomain: "auth.example.com");
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.Equal("auth.example.com", env["ZITADEL_EXTERNALDOMAIN"]);
+ }
+
+ [Fact]
+ public async Task WithExternalDomain_Overrides_Default()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var zitadel = builder.AddZitadel("zitadel")
+ .WithExternalDomain("custom.domain.com");
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.Equal("custom.domain.com", env["ZITADEL_EXTERNALDOMAIN"]);
+ }
+
+ [Fact]
+ public async Task WithExternalDomain_Can_Override_Parameter()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var zitadel = builder.AddZitadel("zitadel", externalDomain: "first.example.com")
+ .WithExternalDomain("second.example.com");
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ // WithExternalDomain should override the parameter
+ Assert.Equal("second.example.com", env["ZITADEL_EXTERNALDOMAIN"]);
+ }
+
+ [Fact]
+ public void WithExternalDomain_Throws_If_Null()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var zitadel = builder.AddZitadel("zitadel");
+
+ var act = () => zitadel.WithExternalDomain(null!);
+
+ Assert.Throws(act);
+ }
+
+ [Fact]
+ public void WithExternalDomain_Throws_If_Empty()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var zitadel = builder.AddZitadel("zitadel");
+
+ var act = () => zitadel.WithExternalDomain("");
+
+ Assert.Throws(act);
+ }
+
+ [Fact]
+ public void WithExternalDomain_Throws_If_Whitespace()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var zitadel = builder.AddZitadel("zitadel");
+
+ var act = () => zitadel.WithExternalDomain(" ");
+
+ Assert.Throws(act);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelIntegrationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelIntegrationTests.cs
new file mode 100644
index 00000000..b5f0db8d
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelIntegrationTests.cs
@@ -0,0 +1,80 @@
+using Aspire.Components.Common.Tests;
+using CommunityToolkit.Aspire.Testing;
+
+namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests;
+
+[RequiresDocker]
+public class ZitadelIntegrationTests(
+ AspireIntegrationTestFixture fixture
+) : IClassFixture>
+{
+ [Fact]
+ public async Task Zitadel_WithPostgres_Starts_And_HealthReady_Ok()
+ {
+ var postgresName = "postgres";
+ var zitadelName = "zitadel";
+
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(postgresName)
+ .WaitAsync(TimeSpan.FromMinutes(3));
+
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(zitadelName)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ var httpClient = fixture.CreateHttpClient(zitadelName);
+ var response = await httpClient.GetAsync("/healthz");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Zitadel_WithPostgres_Env_Is_Applied_And_DbConfig_Is_Valid()
+ {
+ var postgresName = "postgres";
+ var zitadelName = "zitadel";
+
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(postgresName)
+ .WaitAsync(TimeSpan.FromMinutes(3));
+
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(zitadelName)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ var appModel = fixture.App.Services.GetRequiredService();
+ var zitadelResource = appModel.Resources.OfType()
+ .Single(r => r.Name == zitadelName);
+
+ var env = await zitadelResource.GetEnvironmentVariableValuesAsync();
+
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_HOST"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_PORT"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_DATABASE"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_USERNAME"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_PASSWORD"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD"));
+ }
+
+ [Fact]
+ public async Task Zitadel_Admin_Credentials_Are_Set()
+ {
+ var zitadelName = "zitadel";
+
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(zitadelName)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ var appModel = fixture.App.Services.GetRequiredService();
+ var zitadelResource = appModel.Resources.OfType()
+ .Single(r => r.Name == zitadelName);
+
+ var env = await zitadelResource.GetEnvironmentVariableValuesAsync();
+
+ Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME"));
+ Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD"));
+ Assert.NotEmpty(env["ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME"]);
+ Assert.NotEmpty(env["ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD"]);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelWithDatabaseTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelWithDatabaseTests.cs
new file mode 100644
index 00000000..59ca30c4
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelWithDatabaseTests.cs
@@ -0,0 +1,142 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests;
+
+public class ZitadelWithDatabaseTests
+{
+ [Fact]
+ public void WithDatabase_Should_Throw_If_Builder_Is_Null()
+ {
+ IResourceBuilder builder = null!;
+ var app = DistributedApplication.CreateBuilder();
+ var pg = app.AddPostgres("postgres");
+
+ var act = () => builder.WithDatabase(pg);
+
+ var exception = Assert.Throws(act);
+ }
+
+ [Fact]
+ public void WithDatabase_Should_Throw_If_Server_Is_Null()
+ {
+ var app = DistributedApplication.CreateBuilder();
+ var zitadel = app.AddZitadel("zitadel");
+
+ var act = () => zitadel.WithDatabase((IResourceBuilder)null!);
+
+ var exception = Assert.Throws(act);
+ }
+
+ [Fact]
+ public void WithDatabase_Should_Throw_If_Database_Is_Null()
+ {
+ var app = DistributedApplication.CreateBuilder();
+ var zitadel = app.AddZitadel("zitadel");
+
+ var act = () => zitadel.WithDatabase((IResourceBuilder)null!);
+
+ var exception = Assert.Throws(act);
+ }
+
+ [Fact]
+ public async Task WithDatabase_Sets_Default_Database_Name()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var pg = builder.AddPostgres("postgres");
+ var zitadel = builder.AddZitadel("zitadel")
+ .WithDatabase(pg);
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.Equal("zitadel-db", env["ZITADEL_DATABASE_POSTGRES_DATABASE"]);
+ }
+
+ [Fact]
+ public async Task WithDatabase_Uses_Custom_Database_Name()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var pg = builder.AddPostgres("postgres");
+ var zitadel = builder.AddZitadel("zitadel")
+ .WithDatabase(pg, "custom-db");
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.Equal("custom-db", env["ZITADEL_DATABASE_POSTGRES_DATABASE"]);
+ }
+
+ [Fact]
+ public async Task WithDatabase_Sets_Postgres_Environment_Variables()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var pg = builder.AddPostgres("postgres");
+ var db = pg.AddDatabase("zitadel-db");
+
+ var zitadel = builder.AddZitadel("zitadel")
+ .WithDatabase(db);
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_USERNAME"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_PASSWORD"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_HOST"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_PORT"));
+ Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_DATABASE"));
+ }
+
+ [Fact]
+ public async Task WithDatabase_Uses_Server_Parameters()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var pg = builder.AddPostgres("postgres");
+ var db = pg.AddDatabase("zitadel-db");
+
+ var zitadel = builder.AddZitadel("zitadel")
+ .WithDatabase(db);
+
+ var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_USER_USERNAME"]);
+ Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_USER_PASSWORD"]);
+ Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME"]);
+ Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD"]);
+ }
+
+ [Fact]
+ public void WithDatabase_Creates_WaitFor_Dependency()
+ {
+ var app = DistributedApplication.CreateBuilder();
+ var pg = app.AddPostgres("postgres");
+ var db = pg.AddDatabase("zitadel-db");
+
+ var zitadel = app.AddZitadel("zitadel")
+ .WithDatabase(db);
+
+ // The resource should have a WaitFor annotation
+ var waitForAnnotation = zitadel.Resource.Annotations
+ .OfType()
+ .FirstOrDefault();
+
+ Assert.NotNull(waitForAnnotation);
+ }
+
+ [Fact]
+ public void WithDatabase_Creates_Reference()
+ {
+ var app = DistributedApplication.CreateBuilder();
+ var pg = app.AddPostgres("postgres");
+ var db = pg.AddDatabase("zitadel-db");
+
+ var zitadel = app.AddZitadel("zitadel")
+ .WithDatabase(db);
+
+ // The resource should have a ResourceReference annotation
+ var references = zitadel.Resource.Annotations
+ .OfType()
+ .ToList();
+
+ Assert.NotEmpty(references);
+ }
+}