Movatterモバイル変換


[0]ホーム

URL:


blog post image
Andrew Lock avatar

Andrew Lock | .NET EscapadesAndrew Lock

Sponsored byDometrain Courses—Get 30% offDometrain Pro with codeANDREW30 and access the best courses for .NET Developers
~16 min read

Converting a docker-compose file to .NET Aspire

Share on:

In this post, I take adocker-compose.yml file forthe 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 IDEand to generate "publish" artifacts for deploying the app. To prove the app is modelled as expected I subsequentlypublish the app again as adocker-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 Ithink 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 standarddocker-compose.yml file to an Aspire app host. Finally, we'll add a publisher which allows aspire to generate adocker-compose.yml filefrom the app host project, and see how it compares to the original.

What is .NET Aspire?

According tothe 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 thelocal 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, butalso into any apps and services thatuse the database.

Inherently, this configuration isn'thard. After all, we've been doing it for decades. But itis 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 needthis database withthat 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 hostisn't strictly tied to .NET. The appitself is .NET, but that's just the language for modelling the interconnections between services. It can be used to modelany applications, very similar to how adocker-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.

The listmonk app

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 listmonkas a single binary, but there'salso a suggesteddocker-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 usingthe 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 originaldocker-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 namePOSTGRES_PASSWORD:&db-password listmonkPOSTGRES_DB:&db-name listmonkservices:# listmonk appapp:image: listmonk/listmonk:latestcontainer_name: listmonk_apprestart: unless-stoppedports:-"9000:9000"# To change the externally exposed port, change to: $custom_port:9000networks:- listmonkhostname: listmonk.example.com# Recommend using FQDN for hostnamedepends_on:- dbcommand:[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:9000LISTMONK_db__user:*db-userLISTMONK_db__password:*db-passwordLISTMONK_db__database:*db-nameLISTMONK_db__host: listmonk_dbLISTMONK_db__port:5432LISTMONK_db__ssl_mode: disableLISTMONK_db__max_open:25LISTMONK_db__max_idle:25LISTMONK_db__max_lifetime: 300sTZ: Etc/UTCLISTMONK_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:9000volumes:- ./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 databasedb:image: postgres:17-alpinecontainer_name: listmonk_dbrestart: unless-stoppedports:-"127.0.0.1:5432:5432"# Only bind on the local interface. To connect to Postgres externally, change this to 0.0.0.0networks:- listmonkenvironment:<<:*db-credentialshealthcheck:test:["CMD-SHELL","pg_isready -U listmonk"]interval: 10stimeout: 5sretries:6volumes:-type: volumesource: listmonk-datatarget: /var/lib/postgresql/datanetworks: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 hadinstalled 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'svery likely you already have those if you're a .NET developer, which is nice. I primarily usedJetBrains 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 withdotnet new install Aspire.ProjectTemplates:

$ dotnet newinstall Aspire.ProjectTemplatesThe following template packages will be installed:   Aspire.ProjectTemplatesSuccess: 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 wasaspire-apphost. This createsjust 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 LismonkAspirecd LismonkAspiredotnet new aspire-apphost

This created a .NET 9 Aspire 9.3 app host project. TheAppHost.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 tomodel various resources, such as executables, .NET apps, and docker containers. However there are also variousintegrations, 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 installthe relevant NuGet package:

dotnetadd package Aspire.Hosting.PostgreSQL

This installs v9.3.0 of the NuGet package in the project and makes theAddPostgres() 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 namePOSTGRES_PASSWORD:&db-password listmonkPOSTGRES_DB:&db-name listmonkservices:# Postgres databasedb:image: postgres:17-alpinecontainer_name: listmonk_dbrestart: unless-stoppedports:-"127.0.0.1:5432:5432"# Only bind on the local interface. To connect to Postgres externally, change this to 0.0.0.0networks:- listmonkenvironment:<<:*db-credentialshealthcheck:test:["CMD-SHELL","pg_isready -U listmonk"]interval: 10stimeout: 5sretries:6volumes:-type: volumesource: listmonk-datatarget: /var/lib/postgresql/data

The first part of this, thex-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 thedb serviceeffectively has the following environment variables defined:

environment:POSTGRES_USER: listmonkPOSTGRES_PASSWORD: listmonkPOSTGRES_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 secretsvar postgresUser= builder.AddParameter("db-user",secret:true);var postgresPassword= builder.AddParameter("db-password",secret:true);// Add a default for the database namevar postgresDbName= builder.AddParameter("db-name","listmonk",publishValueAsDefault:true);// Create these as variables to be used elsewherevar dbPort=5432;var dbContainerName="listmonk_db";// Sets the POSTGRES_USER and POSTGRES_PASSWORD implicitlyvar 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 usingAddParameter() we can declare that a value should be providedexternally, as it is above fordb-user anddb-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 thedocker-compose.yml thataren't modelled in Aspire, namely the "restart behaviour" and the healthcheck. I left these out, as theAddPostgres() 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 configurationvar 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 browserbuilder.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 thedb 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, thedbContainerName 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 to9000, because that's what the docker-compose file does, but I would probablylike to just be able to have Aspire choose the host port automatically and then have that flow through. Thatalmost 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 usinguser-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-secretsset"Parameters:db-user""listmonk"dotnet user-secretsset"Parameters:db-password""listmonk"dotnet user-secretsset"Parameters:listmonk-admin-user""admin-user"dotnet user-secretsset"Parameters:listmonk-admin-password""admin-password"

Note that the secrets are all nested under theParameters key, by prefixing the values withParameters:. We can now run the app. Either hit F5 in your IDE or typedotnet run, and the app starts up. The Aspire app host startsthe Aspire dashboard, showing our apps. From there we can view the logs, environment, and various other aspects of our apps:

The Aspire dashboard for our listmonk app

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!

The 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 thedocker-compose.yml filefrom the Aspire app host.

Publishing the app host as a docker-compose.yml file

My reason for re-creating thedocker-compose.yml file was to see if thegenerated file looked the same as thesource 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 Aspirepublisher.

A publisher takes your Aspire app host and spits out a bunch of artifacts that can be used by other tools. This could be adocker-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 oftheAspire.Hosting.Docker NuGet package. This provides publishing capabilities for the app host:

dotnetadd package Aspire.Hosting.Docker--version9.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 weneed 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 thelistmonk app, I used thePublishAsDockerComposeService() extension to add therestart: unless-stopped setting to the outputdocker-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 therestart setting and provide the samehealthcheck 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 areonly applied whenpublishing the app, they don't really make sense when running locally.

To use the publisher it seems I needed to installthe Aspire CLI. This is still in preview at the moment, but I couldn't see a way of invoking the publisher without it:

$ dotnet toolinstall--global aspire.cli--prereleaseYou can invoke the tool using the following command: aspireTool'aspire.cli'(version'9.3.0-preview.1.25265.20') was successfully installed.

With the tool installed you can runaspire publish, and the CLI will publish the app host as a.env file and adocker-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-userDB_USER=# Parameter db-passwordDB_PASSWORD=# Parameter db-nameDB_NAME=listmonk# Parameter listmonk-admin-userLISTMONK_ADMIN_USER=# Parameter listmonk-admin-passwordLISTMONK_ADMIN_PASSWORD=

Then we have thedocker-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:falsenetworks:-"aspire"restart:"unless-stopped"healthcheck:test:-"CMD-SHELL"-"pg_isready -U listmonk"interval:"10s"timeout:"5s"retries:6start_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:falsedepends_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 themost 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 managerlistmonk. Listmonk provides adocker-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 adocker-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!)

  • Buy Me A Coffee
  • Donate with PayPal
Loading...
30% off with code ANDREW30 on Dometrain Pro
ASP.NET Core in Action, Third Edition

My new bookASP.NET Core in Action, Third Edition is available now! It supports .NET 7.0, and is available as an eBook or paperback.

Example source code for this post

Tags

Andrew Lock | .Net Escapades
Want an email when
there's new posts?

Stay up to the date with the latest posts!

Oops! Check your details and try again.
Thanks! Check your email for confirmation.

[8]ページ先頭

©2009-2026 Movatter.jp