Two kinds of container

The single most useful thing to understand about Aspire and containers is that "container" means two different things depending on where you are standing. Conflating them is the source of most confusion. Aspire touches containers on both the development side and the deployment side, and the two are wired up by completely different machinery.

01 · DEPENDENCIES

Containers Aspire runs for you

The backing services your app needs - Postgres, Redis, RabbitMQ, MinIO. You declare them in the AppHost; Aspire pulls the images and starts the containers locally when you run aspire run.

02 · PACKAGING

Containers your services become

Your own projects - the API, the worker - packaged as container images at publish time, so they can be deployed the same way the dependencies are.

During local development, the first kind dominates: Aspire is a convenient way to spin up the databases and brokers your app talks to, without you hand-writing docker run commands. At deployment time, the second kind takes over: Aspire helps turn your services into images and emits the manifests that run them. The rest of this article walks both sides.

Container dependencies

Most Aspire integrations for backing services are, under the hood, container resources. When you write AddPostgres or AddRedis in the AppHost, Aspire registers a container that runs the official image for that service. There is also a generic AddContainer for any image you like.

C# AppHost.cs
var builder = DistributedApplication.CreateBuilder(args); // a first-class integration - runs the postgres image var db = builder.AddPostgres("db"); // another integration - runs the redis image var cache = builder.AddRedis("cache"); // any image at all, via the generic AddContainer var minio = builder.AddContainer("storage", "minio/minio"); // the API gets each connection string injected automatically builder.AddProject<Projects.Api>("api") .WithReference(db) .WithReference(cache); builder.Build().Run();

The payoff is the same one Aspire delivers everywhere: you describe the container once, in typed code, and WithReference wires its connection string into whatever services depend on it. No docker run incantations in a README, no copy-pasted connection strings, no "did you remember to start Postgres?" in standups. Run aspire run and the containers come up alongside your projects, all visible together in the Dashboard.

You need a container runtime

This is the part that trips people up. Aspire does not run containers itself - it is an orchestrator, not a container engine. To actually start a container, it hands the work to a container runtime installed on your machine. The moment your AppHost includes a single container resource, you need one of these running:

  • Docker (via Docker Desktop or Docker Engine) - the default and most commonly used runtime.
  • Podman - fully supported as an alternative, and a natural fit for teams that want a daemonless, rootless engine.

Aspire detects which runtime is available and uses it; for most setups the choice is transparent and the same AppHost works with either. The important mental model: Aspire is the conductor, the container runtime is the orchestra. If no runtime is installed and your AppHost declares container resources, aspire run will fail to start them - the error is one of the most common first-time stumbles, and the fix is simply to start Docker or Podman.

One consequence worth noting: a pure-project AppHost with no container resources does not strictly require a runtime. The dependency on Docker or Podman is a function of what your application actually needs, not of using Aspire at all.

Configuring a container resource

A container is rarely just an image name. You usually need to set environment variables, expose ports, mount data, or pin a specific tag. Aspire exposes these as fluent methods on the resource, mirroring the flags you would otherwise pass to docker run.

C# AppHost.cs
var rabbit = builder.AddContainer("broker", "rabbitmq", "3-management") // environment variables passed into the container .WithEnvironment("RABBITMQ_DEFAULT_USER", "app") // publish a port so you can reach the admin UI .WithHttpEndpoint(port: 15672, targetPort: 15672) // a named volume so data survives restarts .WithVolume("broker-data", "/var/lib/rabbitmq");

The names map almost directly onto container concepts you already know: WithEnvironment is -e, WithHttpEndpoint is -p, WithBindMount and WithVolume are the two flavors of -v. The difference is that here they are typed method calls in one file, checked by the compiler and visible to the next developer - rather than shell flags scattered across scripts.

Persistent vs ephemeral

By default, the containers Aspire starts are tied to the lifetime of your aspire run session - stop the app and the containers stop with it. That is usually what you want during active development, since you get a clean slate each run. But it has a cost: a fresh database every time means re-seeding data, and restarting the app means waiting for the image to spin up again.

For containers whose startup is slow or whose data you would rather keep between runs, Aspire offers a persistent lifetime:

C# AppHost.cs
var db = builder.AddPostgres("db") // keep this container alive across app restarts .WithLifetime(ContainerLifetime.Persistent) // and keep its data on a named volume .WithDataVolume();

A persistent container keeps running after you stop the app and is reused on the next aspire run instead of being recreated. Pair it with a data volume and your local Postgres keeps its rows between sessions. The trade-off is the usual one: persistence is convenient but it means state accumulates, so when something gets into a weird state the fix is to remove the container and volume and start clean.

Packaging your services as containers

So far the containers have all been dependencies. The other half of the story is your own code. When you deploy an Aspire application, your projects need to become container images too - that is how they run on platforms like Kubernetes or Azure Container Apps.

Here Aspire leans on tooling that already exists rather than reinventing it. For .NET services, the .NET SDK can build a container image directly from a project - no Dockerfile required - and Aspire's publishing flow uses that to turn each service into an image. When you run the publish step, Aspire produces both the images and the deployment manifests that reference them.

bash
# produce deployment artifacts - images + manifests $aspire publish -o ./artifacts # the same topology you ran locally, now as # container images and a manifest for your target

The key idea is continuity. The dependency you ran as a container locally and the service you wrote as a project both end up as containers in production, described by the same AppHost. You are not maintaining one definition for local dev and a separate, drifting set of Dockerfiles and Compose files for deployment.

Bringing your own Dockerfile

The SDK-based image build is convenient, but sometimes you need full control over how an image is built - a non-.NET service, a custom base image, extra system packages, a specific build stage. For those cases Aspire lets you point a resource at a Dockerfile and have it built as part of the run.

C# AppHost.cs
// build ./worker/Dockerfile and run the result var worker = builder.AddDockerfile("worker", "../worker"); // it behaves like any other container resource worker.WithReference(db) .WithEnvironment("LOG_LEVEL", "debug");

AddDockerfile builds the image from your Dockerfile using the same container runtime, then treats the result like any other container resource - it can take references, environment variables, ports, and volumes. This is the escape hatch that keeps Aspire from boxing you in: when the convenient path does not fit, you drop down to a Dockerfile without leaving the model.

From local containers to production

It is worth restating the boundary, because it is where the two kinds of container finally meet. Aspire is a development-time and publish-time tool, not a production runtime. It runs containers on your machine and it emits the artifacts to run them elsewhere - but it does not host your production traffic. Something else does that: Kubernetes, Azure Container Apps, or another container platform.

What Aspire gives you is a single, code-first description that stays true across that boundary. The Postgres you ran as a local container becomes a managed database or a container in your cluster. The API you wrote as a project becomes an image running next to it. The references you declared become the service-discovery configuration the production platform consumes. For the full picture of how a cloud-connected setup mixes local containers with real cloud services, see What is Hybrid Aspire?.

Containers are not a side feature of Aspire - they are the substrate it orchestrates at both ends. Understanding that the same word covers "the database Aspire starts for me" and "the image my service ships as" is most of what you need to use the two together well.