# Dockerfile Generator for .NET

**Date:** 2024-09-23  
**Author:** Kees C. Bakker  
**Original:** https://keestalkstech.com/dockerfile-generator-for-net/

---

This Dockerfile for .NET was first proposed in the article [Rethinking our ASP.NET Docker CI](https://keestalkstech.com/2023/02/rethinking-our-asp-net-docker-ci/?swcfpc=1). I realised there are some choices to be made, so I've created a small generator to make it easier:

function Model() {
      let self = this;

      this.version = "v2.6.4";
      this.serviceName = ko.observable("Ktt.Docker.Todo.Api");
      this.port = ko.observable("5000");
      this.aspNetVersion = ko.observable("10.0");
      this.addIntegrationTesting = ko.observable(false);
      this.addMultiProjectSupport = ko.observable(true);
      this.otherProjects = ko.observable("");
      this.projectsToPublish = ko.computed(function () {
        let projects = (this.otherProjects() || "").split(" ");
        return ["$MAIN_API_NAME"]
          .concat(projects)
          .filter(x => x)
          .join(" ");
      }, this);

      this.folderStructureTypes = ["root", "src/test", "src/test/shared"];
      this.folderStructure = ko.observable("src/test");

      this.modes = ["restore+build+test+publish", "restore+publish"];
      this.mode = ko.observable("restore+build+test+publish");

      this.addTesting = ko.computed(function () {
        return this.mode().includes("test");
      }, this);

      this.addBuilding = ko.computed(function () {
        return this.mode().includes("build");
      }, this);


      this.timeZone = ko.observable("Europe/Amsterdam");

      this.srcFolder = ko.computed(function () {
        return self.folderStructure() == "root" ? "" : "src/";
      });

      this.echo = ko.observable(true);

      this.code = ko.observable("");

      this.__skip = ["code"];

      this.applyHighlight = function (elements) {
        elements
          .filter(x => x.tagName === "PRE")
          .forEach(x => {
            if (window.hljs) window.hljs.highlightElement(x);
          });
      };
    }

    document.addEventListener("DOMContentLoaded", function (event) {
      if (window.hljs) {
        window.hljs.addPlugin(new CopyButtonPlugin());
      }
      const model = new Model();
      ko.persistChanges(model, "", { storage: new QueryStringStorage() }, 0);
      ko.applyBindings(model, document.getElementById("source"));
    });
  
  
    Service Name:
    Other projects:
    
      Folder structure:
      
    

    
      ASP.NET Version:
    
    Port:
    Time zone:
    Mode:
      
    
    Integration testing:
      [(for test containers)](https://keestalkstech.com/rethinking-our-asp-net-docker-ci/#step-3-integration-testing-stage)
    
    Echo titles:
    Generator
      

  
  #################################
# Configuration settings ()
#################################

ARG MAIN_API_NAME=""
ARG \
   MAIN_API_DLL="$MAIN_API_NAME.dll" \
   PROJECTS_TO_PUBLISH="" \
   PORT= \
   ASPNET_VERSION="" \
   # options: q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic]
   # minimal keeps your pipeline readable while informing you what's going on
   VERBOSITY="minimal" \
   APP_DIR="/app" \
   TAG="" \
   EXECUTE_TESTS="true"


##########################################################################
# Build image, uses VERBOSITY, EXECUTE_TESTS, PROJECTS_TO_PUBLISH, APP_DIR
##########################################################################

FROM mcr.microsoft.com/dotnet/sdk:$ASPNET_VERSION AS build

ARG VERBOSITY EXECUTE_TESTS PROJECTS_TO_PUBLISH APP_DIR

ENV \
   TZ= \
   DOTNET_CLI_TELEMETRY_OPTOUT=true \
   DOTNET_NOLOGO=true

WORKDIR /build

# Let's restore the solution, nuget and project files and do a restore
# in cacheable layers. This will speed up the build process greatly.

# copy global files to restore
COPY *.sln* *.*config ./

# copy files to restore
COPY */*.csproj ./
RUN for file in $(ls *.csproj); do mkdir -p ${file%.*}/ && mv $file ${file%.*}/; done; \
   echo "" \
   && echo "---------" \
   && echo "RESTORING" \
   && echo "---------" \
   && echo "" \
   && for file in $(ls *.csproj); do mkdir -p src/${file%.*}/ && mv $file src/${file%.*}/; done; \
      for p in $(find . -name "*.csproj"); do dotnet restore "$p" || exit 1; done

COPY . .
# copy src files to restore
COPY src/*/*.csproj ./
RUN for file in $(ls *.csproj); do mkdir -p src/${file%.*}/ && mv $file src/${file%.*}/; done

# copy shared files to shared
COPY shared/*/*.csproj ./
RUN for file in $(ls *.csproj); do mkdir -p shared/${file%.*}/ && mv $file shared/${file%.*}/; done

# copy test files to restore
COPY test/*/*.csproj ./
RUN for file in $(ls *.csproj); do mkdir -p test/${file%.*}/ && mv $file test/${file%.*}/; done; \
   echo "" \
   && echo "---------" \
   && echo "RESTORING" \
   && echo "---------" \
   && echo "" \
   && for p in $(find . -name "*.csproj"); do dotnet restore "$p" || exit 1; done

COPY src ./src
COPY shared ./shared
COPY test ./test

# Note on build: don't use --no-restore, sometimes certain packages cannot be
# restored by the dotnet restore. The build will add them, as it has more context (!?)
# example: Package System.Text.Json, version 6.0.0 was not found
RUN echo "" \
   && echo "--------" \
   && echo "BUILDING" \
   && echo "--------" \
   && echo "" \
   && dotnet build --configuration Release --verbosity "$VERBOSITY" -nowarn:NETSDK1004 || exit 1

 # defining the argument here caches the previous layers when the value switches
ARG EXECUTE_TESTS
RUN echo "" \
   && echo "-------" \
   && echo "TESTING" \
   && echo "-------" \
   && echo ""; \
   if [ "$EXECUTE_TESTS" = "true" ]; then \
      dotnet test --filter "Category!=Integration" --configuration Release --logger "console;verbosity=$VERBOSITY" --no-build || exit 1; \
   else \
      echo "Skipping unit tests"; \
   fi;

# publish project(s)
RUN echo "" \
   && echo "----------" \
   && echo "PUBLISHING" \
   && echo "----------" \
   && echo ""; \
   for project in $PROJECTS_TO_PUBLISH; do \
      echo "Publishing $project..."; \
      dotnet publish "$project/$project.csproj" \
        --configuration Release \
        --output "$APP_DIR/$project" \
        -nowarn:NETSDK1004 || exit 1; \
   done


########################
# Integration test image
########################

FROM mcr.microsoft.com/dotnet/sdk:$ASPNET_VERSION AS integration-test

ENV \
   TZ=Europe/Amsterdam \
   DOTNET_CLI_TELEMETRY_OPTOUT=true \
   DOTNET_NOLOGO=true

WORKDIR /build

# install Docker
RUN apt-get update && \
    apt-get install -y --no-install-recommends docker.io procps && \
    rm -rf /var/lib/apt/lists/*

# copy entire build context
COPY --from=build /build /build

# run Docker daemon and tests
CMD ["bash", "-euo", "pipefail", "-c", "\
trap 'echo Shutting down dockerd; pkill dockerd || true' EXIT; \
echo 'Starting Docker daemon...'; \
dockerd > /var/log/dockerd.log 2>&1 & \
for i in {1..30}; do \
  docker info > /dev/null 2>&1 && break || echo \"Waiting for Docker... ($i/30)\"; sleep 1; \
done; \
if ! docker info > /dev/null 2>&1; then \
  echo '❌Docker failed to start. Log output:'; \
  cat /var/log/dockerd.log || echo '(No log found)'; \
  exit 1; \
fi; \
echo '✅ Docker is ready. Running integration tests...'; \
dotnet test --filter 'Category=Integration' --nologo --logger \"console;verbosity=${VERBOSITY:-minimal}\" \
"]


#####################################################################
# Runtime image, uses PORT, TAG, MAIN_API_DLL, MAIN_API_NAME, APP_DIR
#####################################################################

FROM mcr.microsoft.com/dotnet/aspnet:$ASPNET_VERSION-alpine AS runtime

RUN apk add --no-cache icu-libs krb5-libs libgcc libintl libssl3 libstdc++ zlib tzdata

ARG PORT MAIN_API_NAME APP_DIR MAIN_API_DLL TAG

WORKDIR $APP_DIR
COPY --from=build $APP_DIR .

# create a new user and change directory ownership
RUN adduser --disabled-password \
   --home "$APP_DIR" \
   --gecos '' dotnetuser && chown -R dotnetuser:dotnetuser "$APP_DIR"

# impersonate into the new user
USER dotnetuser

ENV \
   ASPNETCORE_URLS=http://*:$PORT \
   ASPNETCORE_ENVIRONMENT=Production \
   TZ= \
   DOTNET_CLI_TELEMETRY_OPTOUT=true \
   DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

EXPOSE $PORT

WORKDIR "$APP_DIR/$MAIN_API_NAME"
ENV PROGRAM="$MAIN_API_DLL"
ENTRYPOINT ["sh", "-c", "exec dotnet \"$PROGRAM\""]

  
  
  


  


  ko.bindingHandlers.saveHtmlContent = {
    init: function (element, valueAccessor) {
      var htmlObservable = valueAccessor();

      // Function to get the inner text only (ignores HTML and comments)
      function extractInnerText(htmlElement) {
        return htmlElement.textContent || htmlElement.innerText;
      }

      // Save the inner text content
      htmlObservable(extractInnerText(element));

      // Set up a MutationObserver to monitor changes in the element's content
      var observer = new MutationObserver(function () {
        htmlObservable(extractInnerText(element));
      });

      // Start observing changes in the child elements
      observer.observe(element, {
        childList: true,
        subtree: true,
        characterData: true
      });

      // Cleanup when the element is removed
      ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
        observer.disconnect();
      });
    }
  };


  #feature-image {
    display: none;
  }

  .entry-content {
    width: 100% !important;
  }

  .grayed-out {
    opacity: 0.5;
    pointer-events: none;
  }

  #source {
    max-width: 100%;

    .form {
      margin-top: 2em;
      margin-bottom: 1em;
      font-family: arial;
      font-size: 14px;
      background-color: var(--light-background);
      padding: 2em;
      column-count: 2;
      column-gap: 2em;

      & > div + div {
        margin-top: 0.5em;
      }

      input {
        background-color: #fff;
        color: #000;
        padding: 2px 2px 2px 10px;
        border-radius: 4px;
      }

      input[type="text"] {
        width: calc(100% - 120px);
      }

      input[type="number"] {
        width: 75px;
      }

      label {
        width: 120px;
        display: inline-block;
      }

      select {
        background-color: white;
        width: calc(100% - 120px);
        height: 2em;
        padding: 4px;
        padding-left: 10px;
        margin-left: -4px;
        border-radius: 4px;
      }
    }

    pre {
      padding: 2em !important;
    }
  }

  .entry-content > p {
    margin-left: 0;
    max-width: 100%;
  }

  @media screen and (max-width: 700px) {
    .form {
      column-count: 1 !important;
    }

    .form br {
      display: none;
    }
  }


  ko.trackChange = (store, observable, key, echo = null) => {
    //initialize from stored value, or if no value is stored yet,
    //use the current value

    const value = store.get(key);
    if (value !== null) {
      if (echo) echo("Restoring value for", key, value);

      //restore current value
      observable(value);
    }

    //track the changes
    observable.subscribe(newValue => {
      if (echo) echo("Storing new value for", key, newValue);
      store.set(key, newValue);
    });
  };

  ko.isComputed = instance => {
    if (!instance || !instance.__ko_proto__) {
      return false;
    }

    if (instance.__ko_proto__ === ko.dependentObservable) {
      return true;
    }

    // Walk the prototype chain
    return ko.isComputed(instance.__ko_proto__);
  };

  const defaultOptions = Object.freeze({
    storage: localStorage,
    traverseNonObservableProperties: true,
    debug: false
  });

  ko.persistChanges = (model, prefix = "model-", options = defaultOptions, deep = 0) => {
    options = Object.assign({}, defaultOptions, options);
    options.echo = function () {
      if (!options.debug) return;

      if (deep > 0) {
        return console.log("-".repeat(deep), ...arguments);
      }
      console.log(...arguments);
    };

    const storageWrapper = {
      set: (key, value) => options.storage.setItem(key, JSON.stringify(value)),
      get: key => JSON.parse(options.storage.getItem(key))
    };

    const skip = new Set(model.__skip || []);
    skip.add("__skip");

    for (let n in model) {
      const observable = model[n];
      const key = prefix + n;

      if (skip.has(n)) {
        options.echo("Skipping", n, "because it is on the __skip list.");
        continue;
      }

      if (ko.isComputed(observable)) {
        options.echo("Skipping", n, "because it is computed.");
        continue;
      }

      if (typeof observable === "function") {
        if (!ko.isObservable(observable)) {
          options.echo("Skipping", n, "because it is a function.");
          continue;
        }

        ko.trackChange(storageWrapper, observable, key, options.echo);
        options.echo("Tracking change for", n, "in", key);
        continue;
      }

      if (!options.traverseNonObservableProperties) {
        options.echo("Skipping", n, "because options.traverseNonObservableProperties is false.");
        continue;
      }

      if (typeof observable === "object" && observable !== null && !Array.isArray(observable)) {
        options.echo("Tracking change for object", key);
        ko.persistChanges(observable, key + "-", options, deep + 1);
        continue;
      }

      options.echo("Skipping", n, observable);
    }
  };


  class QueryStringStorage {
    getItem(key) {
      let value = new URLSearchParams(window.location.search).get(key);

      if (!value) return value;

      if (value != "false" && value != "true" && value.length > 0 && !["{", "[", '"'].includes(value[0])) {
        return '"' + value + '"';
      }

      return value;
    }

    setItem(key, value) {
      const params = new URLSearchParams(window.location.search);

      if (value && value.length > 0) {
        let f = value[0];
        if (f == '"') {
          //string
          value = value.substring(1, value.length - 1);
        }
      }

      params.set(key, value);
      window.history.replaceState({}, "", "?" + params.toString());
    }
  }
