Past Experience

Previously, I have had trouble getting docker running painlessly on both Windows and Linux with dotnet, specifically for development. I just want the containers to update when there are code changes, is that too much to ask for?! In previous projects I would down and up my containers every time there was a change and wait minutes for the containers to rebuild every time just to see if the color of my button changed, this was really frustrating and slow!

This go around, I was committed to doing a little more research to make sure I got it working from the start. Having a flakey development environment causes so much pain over the life of a project, its worth investing a little more time up front.

The goal of this post is to relate some new tools and tips I found that helped me set up a reliable docker development environment for a Dotnet project, with multiple microservices, and a blazor web app.

Tips

Docker Init

First off, did you know docker provides a tool to generate your dockerfiles for dotnet? Simply go to the folder where you have your solution file and run docker init.

This will walk you through some steps and it will generate a dockerfile, dockerignore, and a docker compose file to spin up any project you select! This makes a great starting point.

In my case I did that for each project I wanted to run, and renamed the dockerfiles after the projects… shop.Dockerfile, node.Dockerfile, etc

I moved the compose out into the root of my project and adjusted the build paths (I like being able to start up the compose from the root)

Here’s more resources on that if your interested:

Docker Compose Target

In my case, I’m going to be running a development docker compose, and a production docker compose. Previously I would create different dockerfiles for dev and prod and path to them differently in each compose, but I found you can write different “images” in the same file, and even use shared previous steps, but target different sections in dev and prod.

1
2
3
4
5
6
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS development
...


FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final
...

My shop.Dockerfile has two different sections or “targets” marked as development or as final

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# development docker compose
  shop:
    build:
      context: ./src/Raft/
      dockerfile: shop.Dockerfile
      target: development #targets the development section

# prod docker compose
 shop:
    container_name: cd-shop
    build:
      context: ../../src/Raft/ # my prod one is located elsewhere
      dockerfile: shop.Dockerfile
      target: final # targets the final section

My docker composes target the section they want to build!

Docker Compose Watch

I had no idea that docker compose had a built in feature that can watch files on windows or on linux, and either sync or rebuild containers based on changes to those files! Alternatively this can be done by having your container mounting the file system, but I found this to work much better developing on windows, and much easier to use.

I have a few microservices and a blazor web app, as well as a shared library that contains all the setup needed for them to export telemetry through an open telemetry stack. I want any changes to these projects to sync to the containers, and the containers running the dotnet sdk can then hotreload or rebuild the project without having to take down the containers and rebuild.

Here’s an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
shop:
    ...
    develop:
	  watch:
      - action: sync
        path: ./src/Raft
        target: ./src/
        ignore:
          - ./src/Raft/Raft.Gateway/bin
          - ./src/Raft/Raft.Gateway/obj
          - ./src/Raft/Raft.Node/bin
          - ./src/Raft/Raft.Node/obj
          - ./src/Raft/Raft.Observability/bin
          - ./src/Raft/Raft.Observability/obj
          - ./src/Raft/Raft.Shop/bin
          - ./src/Raft/Raft.Shop/obj
          - ./src/Raft/Raft.Shop.Client/bin
          - ./src/Raft/Raft.Shop.Client/obj

You just add the develop/watch attributes to a docker service. There are multiple possible actions, including syncing files into the container, as well as just rebuilding the container when a file changes, you can learn more here:

Here I am telling docker compose to take any files in ./src/Raft/ and sync them with the files within the container at ./src/ (where my docker file copies them)

The ignore section is important. If you do not ignore the generated build files, as you are developing locally the sdk running on the bare metal of your machine will generate these files, they will be synced into the containers, and the containers sdk will also be trying to generate these, and they will conflict with each other. I know there’s a way to have all you dotnet projects output their bin/obj files to a singular location, but I was lazy here.

Edit: I also discovered that .dockerignore files are respected by the docker watch so the above ignore statement could be revised since bin and obj files are included in my .dockerignore

The final step is to take a look at what command the docker file is running to start the dotnet project.

1
2
3
4
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS development
COPY . /src
WORKDIR /src/Raft.Shop/Raft.Shop
CMD dotnet watch run --no-launch-profile --non-interactive --no-hot-reload

Here you can see my docker file copies my dotnet projects into /src in the container, and runs dotnet watch:

  • --no-launch-profile makes dotnet ignore any profile settings that your IDE may have generated
  • --non-interactive makes it so you don’t have to tell the console program to restart by hitting ctrl-r, and it will restart if it needs to
  • --no-hot-reload This will just make dotnet rebuild whenever there’s any changes instead of hot reloading them. I haven’t had much luck with hot reload, and I find just rebuilding when there’s a change to be just as fast most of the time.
  • More here:

Now if you run your docker compose with docker compose watch, docker compose will keep local files in sync with the containers, which will rebuild the projects without you having to do anything.

YAML Anchors

Finally, because I have a lot of services, and some are dependent on one another, I want to apply this same compose watch config to all of them, without having to duplicate my long ignore list everywhere. Docker compose supports anchors, which let you reference sections of config in many places. Learn more here:

First I created an anchor at the top of my compose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
x-dotnet_sync: &dotnet_sync
  develop:
    watch:
      - action: sync
        path: ./src/Raft
        target: ./src/
        ignore:
          - ./src/Raft/Raft.Gateway/bin
          - ./src/Raft/Raft.Gateway/obj
          - ./src/Raft/Raft.Node/bin
          - ./src/Raft/Raft.Node/obj
          - ./src/Raft/Raft.Observability/bin
          - ./src/Raft/Raft.Observability/obj
          - ./src/Raft/Raft.Shop/Raft.Shop/bin
          - ./src/Raft/Raft.Shop/Raft.Shop/obj
          - ./src/Raft/Raft.Shop/Raft.Shop.Client/bin
          - ./src/Raft/Raft.Shop/Raft.Shop.Client/obj

# base config for making multiple nodes
x-node_base: &node_base
  build:
    context: ./src/Raft/
    dockerfile: node.Dockerfile
    target: development
  <<: *dotnet_sync
...

services:
...

And then I used this anchor on any dotnet service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
services:
  shop:
    build:
      context: ./src/Raft/
      dockerfile: shop.Dockerfile
      target: development
    <<: *dotnet_sync

  gateway:
    build:
      context: ./src/Raft/
      dockerfile: gateway.Dockerfile
      target: development
    environment:
    <<: *dotnet_sync
    
  node1:
    <<: *node_base

  node2:
    <<: *node_base

  node3:
    <<: *node_base

Now all these services stay up to date with my file changes and it’s nice and simple to add new projects to the compose watch in one place!