Converting a docker-compose file to .NET Aspire : Andrew Lock
by: Andrew Lock
blow post content copied from Andrew Lock | .NET Escapades
click here to view original post

In this post, I take a docker-compose.yml file for the open-source mailing list manager listmonk, and rewrite it to use .NET Aspire. Functionally, this results in the same app, but as an app directly modelled in .NET it's (theoretically) easier to both run the stack locally with your IDE and to generate "publish" artifacts for deploying the app. To prove the app is modelled as expected I subsequently publish the app again as a docker-compose.yml file, and compare the output.
Note that I have only dabbled with Aspire, not used it in anger, and I haven't got my head around all of the intricacies yet. If you see something weird in this post—I'm not doing something in the best way, or something doesn't work as I think it does—please do leave a comment and correct me!
I start by giving a high-level overview of .NET Aspire and why you might want to use it. I then describe the listmonk app, and show the docker-compose setup we're seeking to implement. Piece by piece, we'll convert the standard docker-compose.yml file to an Aspire app host. Finally, we'll add a publisher which allows aspire to generate a docker-compose.yml file from the app host project, and see how it compares to the original.
What is .NET Aspire?
According to the documentation:
.NET Aspire provides tools, templates, and packages to help you build observable, production-ready apps. Delivered through NuGet packages, .NET Aspire simplifies common challenges in modern app development.
The primary focus of .NET Aspire is the local development experience. It's intended to simplify the interconnections (and associated configuration) that are often required to connect various parts of your system.
For example, most apps require a database. When you're working locally, maybe you spin up a PostgreSQL Docker container, or perhaps you rely on a local SQL Server installation. Either way, there are usernames, passwords, ports, connection strings, database names… all things that you need to feed both into the configuration of the database, but also into any apps and services that use the database.
Inherently, this configuration isn't hard. After all, we've been doing it for decades. But it is annoying and somewhat error prone. What's more, if someone new starts trying to work on the same project, they have to decode all these requirements for running the app before they can get started. With .NET Aspire the goal is to simplify that process.
.NET Aspire has many different parts to it, but at its core, it has an app host project. This is a .NET project which describes and models all the interconnections between your apps. This is where you declare that you need this database with that password, and so on.
There's a lot more to .NET Aspire, especially if you're building .NET applications—you can ensure your .NET apps are automatically injected with connection strings, for example—but the app host isn't strictly tied to .NET. The app itself is .NET, but that's just the language for modelling the interconnections between services. It can be used to model any applications, very similar to how a docker-compose.yml file can model any docker-based applications.
What is listmonk?
Listmonk is a self-hosted newsletter and mailing list manager. It doesn't handle sending emails itself, it relies on third-party services for that. Rather, listmonk handles designing email campaigns, managing subscribers, and performing analytics.
Listmonk is written in Go, using a Vue frontend with Buefy for UI, and is free and open source software licensed under AGPLv3. Being Go, you can run listmonk as a single binary, but there's also a suggested docker-compose.yml for running the application.
For this post, I'm not going to be looking into the listmonk app itself at all. All I'm interested in is whether it's possible to create a .NET aspire app host project for running listmonk using the same suggested setup as the docker-compose file.
For reference, I'm using the docker-compose file at the time of the v5.0.1 release, reproduced below. This file only contains two services:
app
. The listmonk app itself, running as a docker container.db
. A PostgreSQL database, again running as a docker container.
There's a bunch of shared configuration between the two apps, some volumes and bind mounts, and various other docker-compose specific configuration. For the rest of the app we'll aim to convert this entirely to an Aspire app.
For completeness, this is the original docker-compose.yml file we're converting:
x-db-credentials: &db-credentials # Use the default POSTGRES_ credentials if they're available or simply default to "listmonk"
POSTGRES_USER: &db-user listmonk # for database user, password, and database name
POSTGRES_PASSWORD: &db-password listmonk
POSTGRES_DB: &db-name listmonk
services:
# listmonk app
app:
image: listmonk/listmonk:latest
container_name: listmonk_app
restart: unless-stopped
ports:
- "9000:9000" # To change the externally exposed port, change to: $custom_port:9000
networks:
- listmonk
hostname: listmonk.example.com # Recommend using FQDN for hostname
depends_on:
- db
command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
# --config (file) param is set to empty so that listmonk only uses the env vars (below) for config.
# --install --idempotent ensures that DB installation happens only once on an empty DB, on the first ever start.
# --upgrade automatically runs any DB migrations when a new image is pulled.
environment: # The same params as in config.toml are passed as env vars here.
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_db__user: *db-user
LISTMONK_db__password: *db-password
LISTMONK_db__database: *db-name
LISTMONK_db__host: listmonk_db
LISTMONK_db__port: 5432
LISTMONK_db__ssl_mode: disable
LISTMONK_db__max_open: 25
LISTMONK_db__max_idle: 25
LISTMONK_db__max_lifetime: 300s
TZ: Etc/UTC
LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-} # If these (optional) are set during the first `docker compose up`, then the Super Admin user is automatically created.
LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-} # Otherwise, the user can be setup on the web app after the first visit to http://localhost:9000
volumes:
- ./uploads:/listmonk/uploads:rw # Mount an uploads directory on the host to /listmonk/uploads inside the container.
# To use this, change directory path in Admin -> Settings -> Media to /listmonk/uploads
# Postgres database
db:
image: postgres:17-alpine
container_name: listmonk_db
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432" # Only bind on the local interface. To connect to Postgres externally, change this to 0.0.0.0
networks:
- listmonk
environment:
<<: *db-credentials
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- type: volume
source: listmonk-data
target: /var/lib/postgresql/data
networks:
listmonk:
volumes:
listmonk-data:
Before we can get started on the conversion, we'll install the prerequisites for Aspire.
Getting started with Aspire
To work with Aspire, I first made sure I had installed the prerequisites:
- .NET 9 SDK (you can also use .NET 8)
- Docker Desktop for Windows (you can also use other OCI runtimes like Podman)
It's very likely you already have those if you're a .NET developer, which is nice. I primarily used JetBrains Rider to work on the app, but for this post I primarily use the .NET CLI.
Once you have the prerequisites, it's best to install the Aspire project templates. This makes it easy to create new projects. Install the templates with dotnet new install Aspire.ProjectTemplates
:
$ dotnet new install Aspire.ProjectTemplates
The following template packages will be installed:
Aspire.ProjectTemplates
Success: Aspire.ProjectTemplates::9.3.0 installed the following templates:
Template Name Short Name Language Tags
----------------------------- ---------------------- -------- -------------------------------------------------------------------------------
.NET Aspire App Host aspire-apphost [C#] Common/.NET Aspire/Cloud
.NET Aspire Empty App aspire [C#] Common/.NET Aspire/Cloud/Web/Web API/API/Service
.NET Aspire Service Defaults aspire-servicedefaults [C#] Common/.NET Aspire/Cloud/Web/Web API/API/Service
.NET Aspire Starter App aspire-starter [C#] Common/.NET Aspire/Blazor/Web/Web API/API/Service/Cloud/Test/MSTest/NUnit/xUnit
.NET Aspire Test Project (... aspire-mstest [C#] Common/.NET Aspire/Cloud/Web/Web API/API/Service/Test/MSTest
.NET Aspire Test Project (... aspire-nunit [C#] Common/.NET Aspire/Cloud/Web/Web API/API/Service/Test/NUnit
.NET Aspire Test Project (... aspire-xunit [C#] Common/.NET Aspire/Cloud/Web/Web API/API/Service/Test/xUnit
The template I wanted was aspire-apphost
. This creates just the app host project, without creating associated .NET apps or class libraries. I created a new folder, and then created the new project inside it:
mkdir LismonkAspire
cd LismonkAspire
dotnet new aspire-apphost
This created a .NET 9 Aspire 9.3 app host project. The AppHost.cs project contained the following code; effectively an empty app host project:
var builder = DistributedApplication.CreateBuilder(args);
builder.Build().Run();
There's a bunch of additional files, but for now, this was what I was interested in, so I set about modelling the listmonk services in this file. I started with the PostgreSQL database, seeing as the listmonk app depends on it.
Modelling the database in Aspire
The base Aspire app host project includes the ability to model various resources, such as executables, .NET apps, and docker containers. However there are also various integrations, which are NuGet package "plugins", intended to simplify connecting to common services or platforms. There is just such a package for PostgreSQL.
To install the integration, simply install the relevant NuGet package:
dotnet add package Aspire.Hosting.PostgreSQL
This installs v9.3.0 of the NuGet package in the project and makes the AddPostgres()
extension method available. The section of the docker-compose we need to model is this:
x-db-credentials: &db-credentials # Use the default POSTGRES_ credentials if they're available or simply default to "listmonk"
POSTGRES_USER: &db-user listmonk # for database user, password, and database name
POSTGRES_PASSWORD: &db-password listmonk
POSTGRES_DB: &db-name listmonk
services:
# Postgres database
db:
image: postgres:17-alpine
container_name: listmonk_db
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432" # Only bind on the local interface. To connect to Postgres externally, change this to 0.0.0.0
networks:
- listmonk
environment:
<<: *db-credentials
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- type: volume
source: listmonk-data
target: /var/lib/postgresql/data
The first part of this, the x-db-credentials
section, may be unfamiliar to you. It's a way of getting some "code-reuse" in YAML. I'm not going to go into it in detail here. For now, it's enough to know that the db
service effectively has the following environment variables defined:
environment:
POSTGRES_USER: listmonk
POSTGRES_PASSWORD: listmonk
POSTGRES_DB: listmonk
With that in mind, we'll now model this service in Aspire. I've opted for a close-to direct representation, with some exceptions, which I'll describe later. I've annotated the code below to explain what's going on:
var builder = DistributedApplication.CreateBuilder(args);
// Create these values as secrets
var postgresUser = builder.AddParameter("db-user", secret: true);
var postgresPassword = builder.AddParameter("db-password", secret: true);
// Add a default for the database name
var postgresDbName = builder.AddParameter("db-name", "listmonk", publishValueAsDefault: true);
// Create these as variables to be used elsewhere
var dbPort = 5432;
var dbContainerName = "listmonk_db";
// Sets the POSTGRES_USER and POSTGRES_PASSWORD implicitly
var db = builder.AddPostgres("db", postgresUser, postgresPassword, port: dbPort)
.WithImage("postgres", "17-alpine") // Ensure we use the same image as docker-compose
.WithContainerName(dbContainerName) // Use a fixed container name
.WithLifetime(ContainerLifetime.Persistent) // Don't tear-down the container when we stop Aspire
.WithDataVolume("listmonk-data") // Wire up the PostgreSQL data volume
.WithEnvironment("POSTGRES_DB", postgresDbName); // Explicitly set this value, so that it's auto-created
One big advantage of .NET Aspire over YAML is that creating "variables" to share in multiple places is simple and intuitive. Instead of having to create YAML "anchors" and reference them elsewhere, we simply create values and pass them around.
What's more, by using AddParameter()
we can declare that a value should be provided externally, as it is above for db-user
and db-password
. What's more, we can mark the fact that these should be secrets, so that if we publish our Aspire app, Aspire can handle the fact they contain sensitive data.
There's a few things from the docker-compose.yml that aren't modelled in Aspire, namely the "restart behaviour" and the healthcheck. I left these out, as the AddPostgres()
integration adds its own health check, and these are fundamentally specific to docker-compose; we'll look at them again later when we configure a Docker publisher.
Modelling the app in Aspire
With the database implemented, we move on to the listmonk app itself. This, again, is implemented as a Docker container, but there's no helpful integration or extension method for it; we'll have to model this one ourselves. Luckily, there's not much to configure; we're mostly just setting a bunch of environment variables, exposing the app over port 9000, and changing the command used to run the app:
// Optional initial super-user configuration
var listmonkSuperUser = builder.AddParameter("listmonk-admin-user", secret: true);
var listmonkSuperUserPassword = builder.AddParameter("listmonk-admin-password", secret: true);
var publicPort = 9000; // The port to access the app from the browser
builder.AddContainer(name: "listmonk", image: "listmonk/listmonk", tag: "latest")
.WaitFor(db) // The app depends on the db, so wait for it to be healthy
.WithHttpEndpoint(port: publicPort, targetPort: 9000) // Expose port 9000 in the container as "publicPort"
.WithExternalHttpEndpoints() // The HTTP endpoint should be publicly accessibly
.WithArgs("sh", "-c", "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''")
.WithBindMount(source: "./uploads", target: "/listmonk/uploads") // mount the folder ./uploads on the host into the container
.WithEnvironment("LISTMONK_app__address", $"0.0.0.0:{publicPort.ToString()}") // This points to the app itself (used in emails)
.WithEnvironment("LISTMONK_db__user", postgresUser) // Database connection settings
.WithEnvironment("LISTMONK_db__password", postgresPassword)
.WithEnvironment("LISTMONK_db__database", postgresDbName)
.WithEnvironment("LISTMONK_db__host", dbContainerName)
.WithEnvironment("LISTMONK_db__port", dbPort.ToString())
.WithEnvironment("LISTMONK_db__ssl_mode", "disable")
.WithEnvironment("LISTMONK_db__max_open", "25")
.WithEnvironment("LISTMONK_db__max_idle", "25")
.WithEnvironment("LISTMONK_db__max_lifetime", "300s")
.WithEnvironment("TZ", "Etc/UTC")
.WithEnvironment("LISTMONK_ADMIN_USER", listmonkSuperUser) // Optional super-user
.WithEnvironment("LISTMONK_ADMIN_PASSWORD", listmonkSuperUserPassword);
Most of the variables we set are values that are mirrored in the db
configuration. Using variables means we can easily flow the values to both places, which highlights one of the benefits of Aspire.
There's a number of things I'm fairly sure I'm not doing "correctly" here. For example, the
dbContainerName
anddbPort
; I hard-coded those as variables when configuring thedb
service and re-used them here. Is that reasonable? Is that a problem if I (for example) later decide to run my PostgreSQL instance as a service instead of a Docker container? Probably, but that's always something I could address later I guess.Another example is the public port: I hardcoded it to
9000
, because that's what the docker-compose file does, but I would probably like to just be able to have Aspire choose the host port automatically and then have that flow through. That almost works, but I couldn't see an easy way to have it set theLISTMONK_app__address
environment variable to include the host0.0.0.0
the way I need it to, without manually creating anEnvironmentCallbackAnnotation
. And that just got too ugly.
Ignoring the caveats above, theoretically we've now converted the app and can take it for a spin!
Testing it out
Before we can run the app, we first need to set the values for the parameters we defined in our app host. Given that many of these are marked as secrets, we should probably do this using user-secrets when running locally. You can edit the user-secrets for your app host using the IDE, or alternatively, set the values using the command line. I opted for the latter, setting some "super-secret" values.
dotnet user-secrets set "Parameters:db-user" "listmonk"
dotnet user-secrets set "Parameters:db-password" "listmonk"
dotnet user-secrets set "Parameters:listmonk-admin-user" "admin-user"
dotnet user-secrets set "Parameters:listmonk-admin-password" "admin-password"
Note that the secrets are all nested under the Parameters
key, by prefixing the values with Parameters:
. We can now run the app. Either hit F5 in your IDE or type dotnet run
, and the app starts up. The Aspire app host starts the Aspire dashboard, showing our apps. From there we can view the logs, environment, and various other aspects of our apps:
We can also see a link to our listmonk app at http://localhost:9000. Clicking the link opens the app, where we can login with our Admin username and password. And there we have it, our listmonk app, running in Aspire!
With the app modelled in Aspire, I decided to see what it would like if we went the other way: creating the docker-compose.yml file from the Aspire app host.
Publishing the app host as a docker-compose.yml file
My reason for re-creating the docker-compose.yml file was to see if the generated file looked the same as the source file. If so, then I could be pretty comfortable that Aspire was doing what I intended, at the modelling worked correctly. To do this, I needed to configure an Aspire publisher.
A publisher takes your Aspire app host and spits out a bunch of artifacts that can be used by other tools. This could be a docker-compose.yml file, which is what I wanted, but it could also be Kubernetes Helm charts, it could be Azure ARM/Bicep templates, or anything really. This part of the Aspire experience is less mature than the local-dev-loop, but with 9.3 it's looking much more promising.
I started by installing the preview package of the Aspire.Hosting.Docker NuGet package. This provides publishing capabilities for the app host:
dotnet add package Aspire.Hosting.Docker --version 9.3.0-preview.1.25265.20
Next, we enable the publisher by adding the following to our app host:
builder.AddDockerComposeEnvironment("docker-compose");
That's all we need to do, but I decided to make a few tweaks to the app host, to ensure we more closely replicate the final docker-compose file.
First, for the listmonk
app, I used the PublishAsDockerComposeService()
extension to add the restart: unless-stopped
setting to the output docker-compose.yml file.
builder.AddContainer(name: "listmonk", image: "listmonk/listmonk", tag: "latest")
// ... other config not shown
.PublishAsDockerComposeService((resource, service) =>
{
service.Restart = "unless-stopped";
});
Similarly, for the database service, I used the same method to set the restart
setting and provide the same healthcheck
as the original docker-compose file used:
var db = builder.AddPostgres("db", postgresUser, postgresPassword, port: dbPort)
// ... other config not shown
.PublishAsDockerComposeService((resource, service) =>
{
service.Restart = "unless-stopped";
service.Healthcheck = new()
{
Interval = "10s",
Timeout = "5s",
Retries = 6,
StartPeriod = "0s",
Test = ["CMD-SHELL", "pg_isready -U listmonk"]
};
});
Note that these settings are only applied when publishing the app, they don't really make sense when running locally.
To use the publisher it seems I needed to install the Aspire CLI. This is still in preview at the moment, but I couldn't see a way of invoking the publisher without it:
$ dotnet tool install --global aspire.cli --prerelease
You can invoke the tool using the following command: aspire
Tool 'aspire.cli' (version '9.3.0-preview.1.25265.20') was successfully installed.
With the tool installed you can run aspire publish
, and the CLI will publish the app host as a .env file and a docker-compose.yml file:
> aspire publish -o publish
🛠 Generating artifacts...
✔ Publishing artifacts ━━━━━━━━━━ 00:00:00
👍 Successfully published artifacts to: D:\repos\ListmonkAspire\publish
The files produced are shown below. First of all, we have the .env file, which is where all the parameters that we set in user-secrets are set when running in a docker-compose deployment:
# Parameter db-user
DB_USER=
# Parameter db-password
DB_PASSWORD=
# Parameter db-name
DB_NAME=listmonk
# Parameter listmonk-admin-user
LISTMONK_ADMIN_USER=
# Parameter listmonk-admin-password
LISTMONK_ADMIN_PASSWORD=
Then we have the docker-compose.yml itself. That file is reproduced below
services:
db:
image: "docker.io/postgres:17-alpine"
container_name: "listmonk_db"
environment:
POSTGRES_HOST_AUTH_METHOD: "scram-sha-256"
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256"
POSTGRES_USER: "${DB_USER}"
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: "${DB_NAME}"
ports:
- "5432:5432"
volumes:
- type: "volume"
target: "/var/lib/postgresql/data"
source: "listmonk-data"
read_only: false
networks:
- "aspire"
restart: "unless-stopped"
healthcheck:
test:
- "CMD-SHELL"
- "pg_isready -U listmonk"
interval: "10s"
timeout: "5s"
retries: 6
start_period: "0s"
listmonk:
image: "listmonk/listmonk:latest"
command:
- "sh"
- "-c"
- "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"
environment:
LISTMONK_app__address: "0.0.0.0:9000"
LISTMONK_db__user: "${DB_USER}"
LISTMONK_db__password: "${DB_PASSWORD}"
LISTMONK_db__database: "${DB_NAME}"
LISTMONK_db__host: "listmonk_db"
LISTMONK_db__port: "5432"
LISTMONK_db__ssl_mode: "disable"
LISTMONK_db__max_open: "25"
LISTMONK_db__max_idle: "25"
LISTMONK_db__max_lifetime: "300s"
TZ: "Etc/UTC"
LISTMONK_ADMIN_USER: "${LISTMONK_ADMIN_USER}"
LISTMONK_ADMIN_PASSWORD: "${LISTMONK_ADMIN_PASSWORD}"
ports:
- "9000:9000"
volumes:
- type: "bind"
target: "/listmonk/uploads"
source: "D:\\repos\\blog-examples\\ListmonkAspire\\uploads"
read_only: false
depends_on:
db:
condition: "service_started"
networks:
- "aspire"
restart: "unless-stopped"
networks:
aspire:
driver: "bridge"
volumes:
listmonk-data:
driver: "local"
There's a few cosmetic differences between this generated file and the original, but for the most part they seem functionally equivalent to me! With a bit more work I'm sure I could make them identical, but they're close-enough for me for this experiment. Overall, I'd say it was a success!
Summary
In this post I described .NET Aspire and the open-source mailing-list manager listmonk. Listmonk provides a docker-compose.yml file as a suggested approach to deployment, and I wanted to see how easy it would be to convert the project to run as a .NET Aspire app host instead. The listmonk app runs a PostgreSQL docker container as the database and a separate Docker container as the main app.
After the conversion, I used the Aspire CLI and the Docker publisher to export the Aspire app as a docker-compose.yml file, to see how close the conversion was. Overall I think the experiment was a success, though I'm sure there are things I could do better in the Aspire app (let me know in the comments if you have suggestions!)
May 27, 2025 at 02:30PM
Click here for more details...
=============================
The original post is available in Andrew Lock | .NET Escapades by Andrew Lock
this post has been published as it is through automation. Automation script brings all the top bloggers post under a single umbrella.
The purpose of this blog, Follow the top Salesforce bloggers and collect all blogs in a single place through automation.
============================

Post a Comment