Telerik blogs

The .NET Aspire series continues with integrations. We’ll add SQL Server and Redis integrations and show how Aspire handles it for us.

This is the fourth part of a six-part exploratory series on .NET Aspire.

Hello again, friends.

In the first three installments of this .NET Aspire series, we did the following:

  • Said hi to .NET Aspire and saw how its opinionated approach removes a ton of cloud-native pain.
  • Fired up the Developer Dashboard to watch traces, logs and metrics without juggling terminal windows.
  • Met Service Defaults and learned how a single extension method sprinkles telemetry, health checks and resilience across every project.

Today we’ll turn infrastructure into C# code. We’ll grab my hacked-together SQL Server container out of Docker Compose, drop it into Aspire and speed up reads with a Redis cache. Both resources spin up, wire up and light up in the dashboard. No port-mapping gymnastics required.

Enough talk! Bring on the code.

Add a Containerized SQL Server to .NET Aspire

My SQL Server container works in Docker Compose but it’s a second-class citizen. By inviting it to Aspire, we get:

  • A shared toolchain: We use one AppHost, one launch profile and one CI pipeline.
  • No extra docker compose up step: Hit F5 (or dotnet run) and SQL starts with everything else.
  • Automatic health probes, discovery and secrets: Service Defaults injects connection strings and OTLP endpoints for us.
  • Native observability: Logs, traces and metrics flow straight to the developer dashboard.

Add SQL Server as an Aspire Resource

To get this party started, let’s navigate to our AppHost project and update Program.cs. After you review the code, we’ll walk through it in detail.

var builder = DistributedApplication.CreateBuilder(args);

var password = builder.AddParameter("password", secret: true);
var server = builder.AddSqlServer("server", password, 1433)
        .WithDataVolume("guitar-data")
        .WithLifetime(ContainerLifetime.Persistent);

var db = server.AddDatabase("guitardb");

var api = builder.AddProject<Projects.Api>("api")
    .WithReference(db)
    .WaitFor(db);

// The following code is the same as before
builder.AddProject<Projects.Frontend>("frontend")
    .WithReference(api)
    .WaitFor(api)
    .WithExternalHttpEndpoints();

builder.Build().Run();

Securely Capture Secrets

In this example:

var password = builder.AddParameter("password", secret: true);

AddParameter creates a parameter resource. When we set the secret parameter to true, we tell Aspire to treat the value like a secret and makes it easy to override with an environment variable or user secrets during local development.

In our secrets.json (or appsettings.json), I read it in using the following structure:

{
  "Parameters": {
    "password": "Dave#123"
  }
}

Obviously, this is only suitable for local development. When deploying to servers, you’ll want a better security model like Azure Key Vault. In that case, you can update your code to the following:

var keyVault = builder.AddAzureKeyVault("key‑vault");
builder.Configuration.AddAzureKeyVaultSecrets("key‑vault");   

var sqlPassword = builder.AddParameter("sql-password", secret: true);

Spin Up a SQL Server with Storage

For this example:

var server = builder.AddSqlServer("server", password, 1433)
        .WithDataVolume("guitar-data")
        .WithLifetime(ContainerLifetime.Persistent);

What just happened?

  • AddSqlServer pulls the official mcr.microsoft.com/mssql/server:2022-latest image, sets SA_PASSWORD to our password, and maps it to port 1433.
  • WithDataVolume("guitar-data") mounts a named Docker volume so our container survives rebuilds.
  • Finally, WithLifetime(ContainerLifetime.Persistent) tells Aspire to reuse the same instance between runs. This avoids slow startup times and having to run migrations every time.

Adding the Database

This one-liner …

var db = server.AddDatabase("guitardb");

… is syntactic sugar for writing a CREATE DATABASE guitardb command when we first launch, and also generates a strongly typed connection string resource. That connection string can now be referenced by any project that needs it.

We’ve added the database, but it’s empty and we need to seed it. Starting with Aspire 9.2, you can use the WithCreationScript method.

With a script (or set of scripts) in hand, make sure you’re copying it to the AppHost output directory by adding this to your project file:

<ItemGroup>
  <None Include="..\Api\data\createdb.sql" Link="createdb.sql">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

We can now update the AppHost’s Program.cs database code to the following.

var password = builder.AddParameter("password", secret: true);
var server = builder.AddSqlServer("server", password, 1433)
        .WithDataVolume("guitar-data")
        .WithLifetime(ContainerLifetime.Persistent);

var scriptPath = Path.Join(Path.GetDirectoryName(typeof(Program).Assembly.Location), "createdb.sql");
var db = server.AddDatabase("guitardb").WithCreationScript(File.ReadAllText(scriptPath));

This is just one of many ways you can see data. To learn more, read the Microsoft Docs.

Register the API with the Database

Finally, this code registers the API to the database.

var api = builder.AddProject<Projects.Api>("api")
    .WithReference(db)
    .WaitFor(db);

The WithReference(db) injects two things into the container:

  • The connection string as ConnectionStrings__guitardb
  • The health-probe address for the SQL container so your app knows when the database is up

The WaitFor(db) line means Aspire won’t even docker run the API until SQL Server reports it as healthy.

Update the Inventory API

Because we already rely on Service Defaults, the API can pick up builder.Configuration.GetConnectionString("db") automatically. The only code we need to touch is making sure EF Core is registered to use the database in the API project’s Program.cs:

builder.Services.AddSqlServer<GuitarStoreContext>("guitardb");

That single line allows the AppHost project to spin up the SQL Server container and inject its connection string into the API at launch. Service Defaults then wires up logging, tracing, metrics and resilience. As a result, the AddSqlServer<GuitarStoreContext>("guitardb") call grabs the injected connection string, registers the DbContext and plugs into our observability out of the box.

Examine the Dashboard

When we fire up the app, we now see the database up and running alongside our app.

dashboard with sql

If we review the details of the API resource, we see the injected connection string, too.

db env variable

Our telemetry lights up: if we browse to our traces, we can see a request end-to-end.

db trace

Our database is officially out of Docker Compose and into Aspire! We can now delete the old docker-compose.yml and use SQL Server alongside the rest of our project with a single F5 command (or dotnet run).

We can now move onto our Redis integration to speed up our reads.

Adding Redis for Lightning-fast Caching

Our /guitars endpoint is read-heavy. To make it lightning-fast (and to avoid round-trips to the database), we’ll add Redis as a cache layer and let Aspire wire things up.

To wire Redis in AppHost, we’ll add this right after the SQL snippet.

var cache = builder.AddRedis("cache")
                   .WithRedisInsight()
                   .WithLifetime(ContainerLifetime.Persistent);

What happens here? We pull a Redis container from docker.io/library/redis that helps to avoid any local installs or port wrangling. We also get a PING health probe that is viewable from the /health endpoint and the developer dashboard.

Then, the connection string ConnectionStrings:cache gets injected automatically into any project that calls WithReference(cache).

Optionally, we can also include our favorite Redis admin UI, as Aspire supports Redis Insight or Redis Commander. I’ve added Redis Insight. When I do so, Aspire grabs docker.io/redis/redisinsight to allow me to easily work with my Redis instance.

Much like with our SQL Server container, we add .WithLifetime(ContainerLifetime.Persistent) to tell Aspire, “Don’t tear down the container when I stop debugging.” The result is much faster start-ups and durable state between sessions—great for day-to-day development, demos or long-running background jobs. If you ever need a clean slate, you can still prune the container manually or switch the lifetime back to the default ContainerLifetime.Transient mode.

Here’s my entire Program.cs in AppHost:

var builder = DistributedApplication.CreateBuilder(args);

var password = builder.AddParameter("password", secret: true);
var server = builder.AddSqlServer("server", password, 1433)
        .WithDataVolume("guitar-data")
        .WithLifetime(ContainerLifetime.Persistent);

var db = server.AddDatabase("guitardb");

var cache = builder.AddRedis("cache")
            .WithRedisInsight()
            .WithLifetime(ContainerLifetime.Persistent);

var api = builder.AddProject<Projects.Api>("api")
    .WithReference(db)
    .WithReference(cache)
    .WaitFor(db);

builder.AddProject<Projects.Frontend>("frontend")
    .WithReference(api)
    .WaitFor(api)
    .WithExternalHttpEndpoints();

builder.Build().Run();

Update Our API to Use Redis

How do we update the API to use Redis? Let’s sprinkle some minimal caching over the GET /guitars endpoint. After installing the StackExchange.Redis package, we can add a decorator to use output caching.

app.UseOutputCache();

app.MapGet("/guitars",
   [OutputCache(Duration = 30)] async (GuitarStoreContext db) =>
       await db.Guitars
               .OrderBy(g => g.Brand).ThenBy(g => g.Model)
               .Select(g => new { g.Id, g.Brand, g.Model, g.Series, g.Price, g.SalePrice })
               .ToListAsync());

The output caching middleware can now intercept requests after Aspire registers the Redis store. For testing, we are using a 30-second TTL, but once we go live we can adjust it through configuration.

Let’s now run the solution and open the dashboard. Our resources keep building up.

dashboard with cache

If we browse to our Redis Insight URL, we can see the cache!

redis insight

When we fire off some calls, we now see an end-to-end request completing in under 3.43ms. Not bad!

redis traces

With SQL Server and Redis in play, our dashboard now shows a dependency graph pointing to both SQL and Redis nodes.

dependency graph

Developers Are Happy

Before Aspire (and Docker containers), can you imagine all the work getting a new developer access to a local environment with multiple APIs, logging, a SQL server and a Redis instance?

Now, here’s all we need to do:

git clone https://github.com/davesguitarshop
cd inventory
dotnet run --project GuitarShop.AppHost

With one single command we restore NuGet packages, build projects, start our SQL Server and Redis containers, launch our dashboard and open a browser tab at https://localhost:5001. No SQL installations, no docker compose up and no “What ports do we need to use again?” What a world.

A Sneak Peek: Getting to the Cloud

While developer happiness is huge, the real payoff comes when we push up to the cloud—to Azure Container Apps, for example. Because the same AppHost definition travels with us, the pipeline only needs:

az containerapp up \
  --name guitar-shop \
  --resource-group guitar-shop-rg \
  --subscription guitar-shop-prd

As a result, Aspire’s Azure Container App extension will map:

  • AddSqlServer - Azure SQL (or bring your own image)
  • AddRedis - Azure Cache for Redis (or your own image)
  • Health probes - Kubernetes-style liveness and readiness endpoints
  • OTLP - Azure Monitor / AppInsights

We will have much more in Part 6—stay tuned!

Wrapping Up (And What’s Next)

We just replaced a huge docker-compose.yml, a half‑dozen environment‑variable gymnastics and a README full of “run this before that” commands with just a few lines of C#—all discoverable from IntelliSense. Are you not entertained?

Key takeaways:

  • Integrations are resources, not chores. After declaring intent with AddSqlServer and AddRedis, Aspire handles images, ports and health.
  • Connection strings flow automatically.
  • Observability is built in. Every query, cache hit and exception shows up in the dashboard with zero extra code.
  • Local developer setup is trivial. New devs run one command and start coding.
  • Portability. The same AppHost definition deploys to Azure Container Apps, AKS or Docker Swarm with minimal tweaks.

In Part 5, we’ll dive into service discovery and orchestration—how Aspire lets services talk to each other without hard‑coding URLs and how Polly policies keep things resilient when the network gets mad.

Until then, happy coding and see you soon!


Dave-Brock-headshot-sq
About the Author

Dave Brock

Dave Brock is a software engineer, writer, speaker, open-source contributor and former Microsoft MVP. With a focus on Microsoft technologies, Dave enjoys advocating for modern and sustainable cloud-based solutions.

Related Posts

Comments

Comments are disabled in preview mode.