# Elasticsearch + Kibana in Aspire: solving authentication with lifecycle hooks
Table of Contents
I wanted to run Elasticsearch with Kibana in Aspire. Should be easy, right? There’s already a NuGet package: Aspire.Hosting.Elasticsearch.
Except it only gives you Elasticsearch. No Kibana.
Fine. I’ll add Kibana as a container myself. Point it at Elasticsearch:
var elasticsearch = builder.AddElasticsearch("elasticsearch", password);
var kibana = builder .AddContainer("kibana", "docker.elastic.co/kibana/kibana", "8.17.0") .WithEnvironment("ELASTICSEARCH_HOSTS", elasticsearch.GetEndpoint("http")) .WithEnvironment("ELASTICSEARCH_USERNAME", "kibana_system") .WithEnvironment("ELASTICSEARCH_PASSWORD", password);Hit F5. Kibana refuses to start.
Authentication of [kibana_system] was terminated by realm [reserved] -failed to authenticate user [kibana_system]Turns out there’s a catch
Elasticsearch has a built-in user called kibana_system. It exists. It’s pre-created. But it doesn’t have a password.
This is by design - Elasticsearch creates several built-in users (elastic, kibana_system, apm_system, etc.) but leaves their passwords unset for security. You’re supposed to configure them during setup.
Kibana needs this user to connect. You configure the username and password in environment variables, Kibana tries to authenticate, and Elasticsearch says “nope, password doesn’t match” - because nobody set a password for that user yet.
Authentication of [kibana_system] was terminated by realm [reserved] -failed to authenticate user [kibana_system]That “failed to authenticate” doesn’t mean wrong password - it means no password was ever set.
Most Docker Compose setups solve this with shell scripts that run after containers start. Wait for Elasticsearch to be healthy, curl an endpoint, set the password, done. It works, but the logic lives outside your code.
I wanted this in pure C#. Something every developer on the team can read and understand without hunting for bash scripts in the repo.
The Aspire-native fix: lifecycle hooks
Aspire 9.0 introduced lifecycle hooks - events that fire at specific points in your application’s lifecycle:
- BeforeStartEvent - Fires before the application starts, good for validation or pre-flight checks
- AfterEndpointsAllocatedEvent - When all service endpoints are assigned, useful if you need to know URLs before resources start
- AfterResourcesCreatedEvent - After all resources are created but before they start, perfect for cross-resource configuration
- ResourceReadyEvent - When a specific resource becomes healthy, exactly what we need here
I subscribed to ResourceReadyEvent directly in the extension method:
builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(async (evt, ct) =>{ // Only handle events for this specific Elasticsearch resource if (evt.Resource.Name != builder.Resource.Name) return;
if (evt.Resource is not ElasticsearchResource elasticsearchResource) return;
var logger = evt.Services.GetRequiredService<ILogger<ElasticsearchResource>>(); var password = await elasticsearchResource.PasswordParameter.GetValueAsync(ct);
// Set kibana_system password (required for Kibana to connect) await httpClient.PostAsJsonAsync( "/_security/user/kibana_system/_password", new { password });
// Create admin user (for actually using Kibana UI) await httpClient.PostAsJsonAsync( "/_security/user/" + kibanaAdminUsername, new { password, roles = new[] { "kibana_admin", "superuser" }, full_name = "Kibana Administrator" });});No lifecycle hook class. No manual watching. Just subscribe to the event and react when Elasticsearch becomes healthy.
Wrap it in extension methods and you get this:
var password = builder.AddParameter("elastic-password", secret: true);
var elasticsearch = builder .AddElasticsearchWithKibana("elasticsearch", password) .WithElasticsearchSetup();Two lines. Elasticsearch starts, Kibana starts, users get configured automatically, authentication works.
Why this pattern is useful
The same pattern solves a common Aspire problem: “I need to configure something after a container starts.”
The lifecycle hook pattern works for any integration where:
- A container needs setup after it’s running
- That setup requires waiting for the container to be healthy
- You want everything in C# instead of shell scripts
Aspire 9.0’s lifecycle hooks (BeforeStartEvent, AfterResourcesCreatedEvent, ResourceReadyEvent) give you precise control over when things happen. No polling, no background services watching for state changes - just subscribe to the event you care about.
Want to customize the setup? Use the configuration delegates:
var elasticsearch = builder .AddElasticsearchWithKibana( "elasticsearch", password, configureElasticsearch: es => es .WithEnvironment("ES_JAVA_OPTS", "-Xms2g -Xmx2g"), configureKibana: kibana => kibana .WithEnvironment("LOGGING_ROOT_LEVEL", "debug")) .WithElasticsearchSetup( kibanaAdminUsername: "admin", kibanaAdminFullName: "Administrator");Need multiple instances? It handles that too:
var dev = builder .AddElasticsearchWithKibana("elasticsearch-dev", devPassword) .WithElasticsearchSetup();
var prod = builder .AddElasticsearchWithKibana("elasticsearch-prod", prodPassword) .WithElasticsearchSetup(kibanaAdminUsername: "prod-admin");The lifecycle hook fires for each Elasticsearch resource that’s marked for setup.
What you need to replicate this
Three pieces:
- Extension methods that create containers and provide the fluent API
- A lifecycle hook subscription that reacts to
ResourceReadyEvent - Annotations to pass configuration from extensions to the event handler
The complete implementation is in the docs I linked. The pattern works for any “wait for container, then configure it” scenario.
The result
Hit F5. Both running, connected, working.
If you’re building Aspire integrations and hitting the “I need to configure something after startup” problem, the lifecycle hooks in Aspire 9.0 are exactly what you need.
Note: This setup uses HTTP (not HTTPS) for local development. The user setup calls hit http://localhost:9200 and Kibana connects over HTTP. For production, you’d want HTTPS with proper certificates - maybe a topic for another post.
Full working code: https://gist.github.com/VDBBjorn/983c302708874c8549fac687cca30d4d