Dockerfile Generator for .NET


This Dockerfile for .NET was first proposed in the article Rethinking our ASP.NET Docker CI. 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());
}

}