Featured image of post How Workleap uses .NET Aspire to transform local development

How Workleap uses .NET Aspire to transform local development

We built Leap, an opinionated, reliable CLI tool powered by .NET Aspire to make distributed system development easier for all Workleap developers.

Jessica just joined the team as a developer. After cloning her team’s repos, she runs a single command in her terminal: leap run. Within minutes, she’s looking at a fully functioning dashboard showing a microservice architecture with backends, frontends, databases, and messaging services - all running locally on her machine. “That was easy”, she thinks, unaware that months of engineering effort made this magical experience possible.

Behind the scenes, Leap - our Local Environment Application Proxy - orchestrates the setup of several services, containerized or not, configures networking between them, starts emulators and databases, installs a local-only HTTPS certificate, and updates the hosts file with custom domains. What would take a day or more of environment setup now happens automatically in minutes.

This satisfying experience isn’t just the result of clever engineering - it’s made possible by embracing .NET Aspire as the foundation of our solution. In this post, I’ll share how we leveraged Aspire in unique and creative ways to create Leap and revolutionize our local development workflow.

Our local development challenge

Workleap has embraced microservice architectures for numerous advantages - improved scalability, team autonomy, and technological flexibility. However, these benefits come with a significant cost: setting up a local development environment became more complex.

A typical developer at our organization might need to interact with:

  • A few back-end services and frontends.
  • Various databases (MongoDB, SQL Server, etc.).
  • Cloud resources (Azure Blob Storage, Azure Event Grid, etc.).

Before Leap, each team had its own approach to local development, leading to a fragmented experience. Custom scripts and Docker Compose files, mostly copied from one project to another - but always with differences - contributed to a lack of consistency and standardization.

Port management was particularly frustrating. Developers had to assign localhost ports for backends, frontends, and third-party dependencies. They needed to “negotiate” with other teams to avoid conflicts when they worked together. Keeping the default ports (3000 for React frontends, 27017 for MongoDB, etc.) was common and caused errors when switching between projects.

To develop a single service, developers needed to know how to start multiple dependent services, clone various repositories, run different setup scripts, and maintain knowledge of exact service localhost URLs. HTTPS support was built in silos, with each team managing its own certificates, at the risk of incompatibility when starting multiple services.

These challenges were identified from company-wide developer surveys where local development complexity ranked as our developers’ #1 pain point. After analyzing the feedback patterns, our Platform Engineering team recognized the opportunity to create a golden path - a standardized, well-supported development workflow that would dramatically improve productivity across all teams.

Transforming local development with Leap

About two years ago, Leap emerged as an answer to these challenges - an opinionated internal CLI tool that streamlines the entire local development workflow, tailored to our developers’ needs. At its core, Leap uses a declarative approach with YAML configuration files that specify services, their dependencies, and how they should run locally. One of our sources of inspiration was Microsoft’s Tye, which is .NET Aspire’s precursor, even though Aspire did not exist at that time.

services:
  backend:
    # Requests to app.workleap.localhost/api will be routed to this service
    ingress:
      host: app.workleap.localhost
      path: /api
    # Service owners can define one or more ways to run a service and let users choose which one to use at runtime
    # This is useful for optimizing resource consumption with pre-built images or service mocks
    runners:
      - type: dotnet
        project: "./backend/backend.csproj"
      - type: docker
        image: demo.azurecr.io/workleap/demo-backend:latest

  frontend:
    ingress:
      host: app.workleap.localhost
    runners:
      - type: executable
        command: "npm"
        args: ["run", "dev"]
        workingDirectory: "./frontend"

# Dependencies are recipes for commonly used third-party services
dependencies:
  - type: mongo
  - type: redis

In this example, we declare two services, a backend API and its frontend. Both are accessible from the same custom local domain. For the backend, there are two runners - ways to start a given service - and developers can choose one in the CLI or in the Aspire dashboard. Finally, we declare a few pre-configured third-party dependencies (more on that later).

leap.yaml file supports a wider range of configuration options, including, but not limited to: custom environment variables, protocol and port overrides, health check URL paths, container volumes, profiles like Docker Compose, additional built-in runners and dependency types.

The leap run command scans for these configuration files, parses them, and orchestrates the entire environment automatically. Leap’s key features include:

  1. Unified service management - Start, stop, and monitor all services from a single dashboard.
  2. Automatic port management - No more port conflicts or manual port assignment.
  3. Custom domain support - Local services accessible via custom domains like app.workleap.localhost.
  4. Intelligent dependency management - MongoDB, Redis, and other dependencies are automatically provisioned and shared across services.
  5. Environment variable standardization - Services discover each other through consistent environment-variable conventions.
  6. Built-in HTTPS - Automatic certificate generation and configuration for secure local development from end to end.
  7. Observability - Integrated logging, metrics, and distributed tracing for debugging complex interactions.
  8. Cross-platform support - Leap works on Windows, macOS, and Linux for x64 and arm64 architectures.
  9. Multi-repository support - Leap supports multiple repositories and solutions by ingesting leap.yaml files from any location on the filesystem.
  10. Multi-language support - Leap can run any .NET project, Docker container, or executable.
  11. Reliable and performant - Leap is a fast, reliable tool that developers can use all day long.

Leap in action!

.NET Aspire: The technological foundation

Microsoft publicly launched the first preview of .NET Aspire a couple of weeks after we started the development of Leap. Back then, we implemented a custom process-orchestration layer on top of Process Compose. There was no dashboard, but we knew we wanted to build one with Process Compose’s API. In terms of observability, only distributed tracing was covered with Jaeger.

Soon, we recognized Aspire’s potential to solve many of our local development challenges. Rather than building our orchestration engine from scratch, we decided to leverage Aspire as our foundation. The available features weren’t enough to cover our needs, but we were able to leverage its extensibility to address that. Like many, we found its dashboard valuable, and the OpenTelemetry viewer was a great opportunity to educate our developers about distributed tracing.

Embedding Aspire in a CLI tool

One of our first challenges was ensuring Leap worked across our diverse development environments. It needed to work as a redistributable, cross-platform tool without requiring developers to install specific Aspire SDKs. At the beginning, Aspire was distributed through .NET workloads, which had several limitations. Leap had to work with multiple existing projects, repositories, and solutions, no matter where they were located.

Even now, an Aspire main project (the app host) can only support a single repository, unless each developer follows the exact same directory conventions. You also can’t publish the app host and reuse it elsewhere. There are specific file and directory paths that end up being embedded in the resulting assembly metadata, like the location of its orchestrator (DCP) or the dashboard binaries. Paths to projects and other resources are also tied to the app-host source code location.

So, we ended up taking a deep dive into Aspire’s source code to understand how to bypass these limitations:

  • We re-enabled the IsPublishable and IsPackable MSBuild properties, which are disabled by default as Aspire doesn’t support publishing.
  • Using NuGet’s .NET client, we download, unzip and cache the orchestration engine and dashboard binaries for the right platform in Leap’s data directory. Their version matches the version of the Aspire libraries used in Leap.
  • With a bit of reflection, we create custom IOptions<DcpOptions> so that Aspire app hosts use these downloaded binaries.
  • Finally, we ensured our custom options are used instead of the assembly metadata by setting the configuration property DcpPublisher:CliPath.
// These are the assembly metadata values injected by Aspire at build time.
// Thankfully, we could craft a custom instance of the internal DcpOptions class to override them.
[assembly: AssemblyMetadata("dcpclipath", "/root/.nuget/packages/aspire.hosting.orchestration.linux-x64/9.2.0/tools/dcp")]
[assembly: AssemblyMetadata("dcpextensionpaths", "/root/.nuget/packages/aspire.hosting.orchestration.linux-x64/9.2.0/tools/ext/")]
[assembly: AssemblyMetadata("dcpbinpath", "/root/.nuget/packages/aspire.hosting.orchestration.linux-x64/9.2.0/tools/ext/bin/")]
[assembly: AssemblyMetadata("apphostprojectpath", "/actions-runner/_work/wl-leap/wl-leap/src/Leap.Cli")]
[assembly: AssemblyMetadata("apphostprojectname", "Leap.Cli.csproj")]
[assembly: AssemblyMetadata("aspiredashboardpath", "/root/.nuget/packages/aspire.dashboard.sdk.linux-x64/9.2.0/tools/Aspire.Dashboard")]
[assembly: AssemblyMetadata("apphostprojectbaseintermediateoutputpath", "/actions-runner/_work/wl-leap/wl-leap/src/Leap.Cli/obj/")]

Reimagining .NET project orchestration

Aspire’s default .NET project orchestration assumes a single .NET solution, and that all referenced projects are already built. Our requirements were more complex - support for multiple repositories, arbitrary project paths, projects not yet built - sometimes freshly cloned.

We ended up rebuilding the default ProjectResource from scratch:

  • Built on top of the built-in ExecutableResource.
  • dotnet watch (default) or dotnet run support with automatic retries for build-race conditions (CS2012 compilation error).
  • Support for debugging on application startup with a custom command that injects a custom .NET startup hook to wait for a debugger to attach.
  • No launch profiles as we trusted Leap to provide startup environment variables, like DOTNET_ENVIRONMENT.
  • Custom configuration for ASP.NET Core URLs and the Kestrel certificate path.
// Part of our custom .NET project resource implementation - there's a lot more
internal static class DotnetExecutableResourceExtensions
{
    public static IResourceBuilder<DotnetExecutableResource> AddDotnetExecutable(
        this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, string projectPath, bool watch)
    {
        string[] args = watch
            ? ["watch", "--project", projectPath, "--no-launch-profile", "--non-interactive", "--no-hot-reload"]
            : ["run", "--project", projectPath, "--no-launch-profile"];

        var resource = new DotnetExecutableResource(name, "dotnet", workingDirectory);

        return builder.AddResource(resource).WithArgs(args);
    }

    // [...]
}

Our custom command to attach to the debugger on startup prints the process ID in the logs.

Dynamic Docker Compose integration

We encountered some bugs and limitations with the built-in Aspire container support in the first previews, especially with teams dealing with large numbers of containers. We decided to rewrite the container orchestration on top of Docker Compose with custom Aspire resources:

  • Generated Docker Compose files dynamically based on declared containerized services in the discovered leap.yaml configurations.
  • Streamed logs, container status, and metadata to the Aspire dashboard in real time using Docker.DotNet library.
  • Managed the container lifecycle independently of the dashboard lifecycle (containers are persistent by default).
  • All containers shared the same network.
  • Re-implemented built-in dashboard commands for starting, stopping, and restarting containers.
  • Configured extra hosts so that containers can resolve custom domains.
  • Configured certificate file mounts to support per-user trusted HTTPS from end-to-end.
  • Configured OpenTelemetry the way Aspire does by passing the right environment variables.
  • Modeled the Docker Compose YAML specification using YamlDotNet.

This approach has proven extremely performant and reliable. At first, we started Docker Compose before the dashboard, which could be slow depending on how many images had to be pulled. At some point, we realized we could launch the dashboard right away and let a custom Aspire lifecycle hook take care of individually starting each Docker Compose service, which made Leap very snappy.

Maybe, in the future, we’ll consider going back to the built-in Aspire container support. But you know the saying: If it ain’t broke, don’t fix it.

# Leap transforms this leap.yaml file...
services:
  aspnetapp:
    ingress:
      host: aspnetapp.workleap.localhost
    runners:
      - type: docker
        image: mcr.microsoft.com/dotnet/samples:aspnetapp
        containerPort: 8080
        protocol: https

# Into this dynamically generated Docker Compose file:
name: "leap"
services:
  aspnetapp:
    image: "mcr.microsoft.com/dotnet/samples:aspnetapp"
    container_name: aspnetapp-e385ce4e
    security_opt:
    - no-new-privileges:true
    restart: no
    pull_policy: missing
    extra_hosts:
    - host.docker.internal:host-gateway
    - aspnetapp.workleap.localhost:host-gateway
    environment:
      ASPNETCORE_URLS: "https://*:8080"
      PORT: "8080"
      OTEL_SERVICE_NAME: "aspnetapp"
      OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
      OTEL_EXPORTER_OTLP_ENDPOINT: "https://host.docker.internal:18889"
      OTEL_EXPORTER_OTLP_HEADERS: "x-otlp-api-key=leap"
      OTEL_BLRP_SCHEDULE_DELAY: "1000"
      OTEL_BSP_SCHEDULE_DELAY: "1000"
      OTEL_METRIC_EXPORT_INTERVAL: "1000"
      OTEL_TRACES_SAMPLER: "always_on"
      OTEL_METRICS_EXEMPLAR_FILTER: "trace_based"
      NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt"
      ASPNETCORE_Kestrel__Certificates__Default__Path: "/etc/ssl/certs/leap-certificate.crt"
      ASPNETCORE_Kestrel__Certificates__Default__KeyPath: "/etc/ssl/certs/leap-certificate.key"
      DOTNET_ENVIRONMENT: "Local"
      Logging__LogLevel__System.Net.Http.HttpClient.OtlpMetricExporter: "Warning"
      Logging__LogLevel__System.Net.Http.HttpClient.OtlpTraceExporter: "Warning"
      OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true"
      OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true"
      OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory"
      OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION: "true"
      OTEL_DOTNET_EXPERIMENTAL_HTTPCLIENT_DISABLE_URL_QUERY_REDACTION: "true"
      DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: "true"
      LOGGING__CONSOLE__FORMATTERNAME: "simple"
      LOGGING__CONSOLE__FORMATTEROPTIONS__TIMESTAMPFORMAT: "yyyy-MM-ddTHH:mm:ss.fffffff "
      IDENTITY_ENDPOINT: "http://host.docker.internal:6501/token"
      IMDS_ENDPOINT: "dummy_required_value"
      Services__aspnetapp__BaseUrl: "https://aspnetapp.workleap.localhost:1347"
    ports:
    - "55336:8080"
    volumes:
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate-ca.crt:/etc/ssl/certs/ca-certificates.crt:ro"
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate-ca.crt:/etc/pki/tls/certs/ca-bundle.crt:ro"
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate-ca.crt:/etc/ssl/ca-bundle.pem:ro"
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate-ca.crt:/etc/pki/tls/cacert.pem:ro"
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate-ca.crt:/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem:ro"
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate-ca.crt:/etc/ssl/cert.pem:ro"
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate.crt:/etc/ssl/certs/leap-certificate.crt:ro"
    - "C:\\Users\\anthony.simmon\\.leap\\generated\\certificates\\leap-certificate.key:/etc/ssl/certs/leap-certificate.key:ro"
networks:
  default:
    name: leap
    driver: bridge

Certificate management and secure communication

HTTPS is built into ASP.NET Core, and our teams were used to configuring HTTPS for frontend dev servers too. This is why we implemented a future-proof local certificate management system that:

  • Automatically installs mkcert on Windows (instructions are provided on Unix-like platforms).
  • Uses mkcert to create and install a local certificate authority if not already created and installed.
  • Generates wildcard certificates for custom local domains (e.g., *.workleap.localhost).
  • Uses a PEM certificate format with a private key instead of PFX and the default changeit password for better compatibility.
  • Certificates also support container hostnames like host.docker.internal.
  • Distributes certificates to both containerized services and services running on the host.
  • Requests administrative rights when needed to update the system hosts file according to the domains specified in leap.yaml files.

This is how we put an end to custom local certificate management scripts in several repositories.

YARP reverse proxy integration

To solve the port management challenge and to support custom domains, we built an integration for YARP, a high-performance HTTP reverse proxy to create a single entry point for all services. Developers must only remember a single port, 1347, which stands for “leap” in leetspeak.

Our YARP integration enables:

  • Routing requests to services based on the requested host.
  • Routing requests to services on the same domain but different paths (e.g., /api).
  • Header preservation for distributed tracing (passthrough).
  • Preserving the original IP address, host, protocol, and path prefix.
  • Automatic HTTP/HTTPS redirection.
  • Custom domain support with a single port and Leap’s certificate.
  • Teams to practice their CORS and cookie policies.

Developers can still specify specific localhost ports for their services in leap.yaml files. This was part of an effort to ease the migration to Leap. Ideally, they let Leap assign random ports to their services on localhost.

Cloud emulators and third-party dependencies

For the most commonly used cloud services and databases our applications depend on, we created custom Aspire resources that provide local emulation:

  • MongoDB configured as a single-node replica set for transactions and change streams support.
  • Azurite (Azure Storage emulator) on HTTPS, which enables Azure Identity support (DefaultAzureCredential, etc.) without connection strings or any kind of secrets.
  • Redis with local-development optimized configuration.
  • Our Azure Event Grid emulator with declarative topics and subscription management in YAML. Topics and subscriptions from all leap.yaml files are automatically merged by Leap at startup time.
  • SQL Server and PostgreSQL.

All of these are powered by our custom Docker Compose integration, so they are persistent and use volumes by default. They always start first, and other services depend on them using Aspire’s WaitFor feature.

For each dependency, we decided to use distinct fixed, well-known ports than the default ones. This way, Leap does not conflict with existing local setups.

We fine-tuned each dependency to provide the best local development experience.

Azure CLI integration

At Workleap, we use Azure identities to access Azure resources in production. This approach is more secure than using connection strings and secrets. During local development, developers use the Azure CLI, which authenticates against Microsoft Entra ID with their credentials. Services would then use the Azure SDK with DefaultAzureCredential to access them.

We quickly identified a significant performance bottleneck: DefaultAzureCredential takes up to 10 seconds to initialize when using Azure CLI credentials, causing frustrating delays during frequent service restarts. To solve this, we implemented a custom Aspire resource that acts as an Azure managed credential proxy between services to access local Azure CLI credentials. This reduces Azure credentials lookup time from 10 seconds to less than one second. It also allows containerized services to access Azure CLI credentials from the host machine, which is publicly known to be incredibly difficult to achieve.

Prioritizing developer experience and reliability

When building development tools, reliability is essential. Leap was meant to become a critical part of our developers’ daily workflow, and any flakiness directly impacts productivity, causes frustration, and eventually damages trust in the tool and the team behind it. We approached Leap’s design with the same standards we would apply to production services.

Performance and reactivity

Developers have high expectations from their tools. Leap can open the dashboard in a matter of seconds. For many months, very few bugs have been reported. Our strict adherence to C# null-reference types, zero-trust stance toward user input, and our .NET coding standards has significantly contributed to this stability. Some developers keep Leap open all day long and leverage start, stop, and restart commands, as well as dotnet watch, to restart their services. Cancellation tokens are used everywhere to ensure that long-running tasks can be interrupted gracefully at any time.

Robust error handling and pragmatic testing

We implemented a comprehensive error handling system that:

  • Provides clear, actionable error messages for common failures or misconfigurations.
  • Includes a customizable verbosity level to access Aspire logs when needed.
  • Makes errors as actionable as possible.

We have almost no unit tests. Most of our confidence in changes comes from a few integration tests where we start Leap for real. They cover many critical paths and use cases on the three main operating systems. This proved to be a worthwhile choice and allowed us to evolve the tool very quickly.

Telemetry for product improvement

To continuously improve Leap, we implemented an OpenTelemetry-based telemetry system that collects anonymous usage data. This data provides invaluable insights into:

  • How many developers actively use Leap.
  • Which features and dependencies are most valuable.
  • Performance metrics and statistics.
  • Spans for HTTP requests and child processes.

Preemptive environment verification

Many local development issues are caused by misconfigured environments. Leap proactively checks for:

  • Docker to be up and running, ready to accept connections.
  • Whether developers are authenticated in Azure CLI.
  • Whether developers are authenticated against container registries.
  • Available Leap updates (npm style).

When issues are detected, Leap provides specific guidance on how to resolve them.

Conclusion

While we didn’t use Aspire in its conventional form, our changes demonstrate its flexibility as a foundation for developer tooling. By extending and customizing its core components, we created a solution that aligns with our organization’s needs. The beauty of this approach is that developers can leverage Aspire’s powerful capabilities without needing to learn how it works. This abstraction allows our teams to focus on what they do best.

Building Leap with .NET Aspire has been a transformative experience. Aspire’s orchestration capabilities, combined with its extensible application model, allowed us to create a robust developer experience that would have been nearly impossible to build from scratch.

Leap logo after it became an internal brand at Workleap.

Today, Leap has evolved beyond a tool into an internal brand at Workleap, embodying our Platform Engineering team’s platform as a product philosophy. While this article focused on local development, Leap’s scope has expanded to include project scaffolding, CI/CD integration, and a robust developer portal. By approaching our internal developer platform with the same care and user focus we apply to customer-facing products, we’ve created a solution our teams genuinely enjoy using - accelerating collaboration and value delivery across the organization.


Photo of our office in Montréal by Claude-Simon Langlois

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