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;
}

}

const createStorageWrapper = storage => ({ set: (key, value) => { try { const json = JSON.stringify(value);

    if (json === undefined) {
      storage.removeItem(key);
      return;
    }

    storage.setItem(key, json);
  } catch (error) {
    console.warn("Could not store", key, error);
  }
},
get: key => {
  try {
    const json = storage.getItem(key);

    if (json === null) {
      return { found: false };
    }

    return { found: true, value: JSON.parse(json) };
  } catch (error) {
    console.warn("Could not restore", key, error);
    return { found: false };
  }
}

});

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

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

  //restore current value
  observable(stored.value);
}

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

};

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

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

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

if (visited.has(model)) {
  options.echo("Skipping", prefix, "because the object was already visited.");
  return;
}
visited.add(model);

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

for (const n of Object.keys(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, visited);
    continue;
  }

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

};

class QueryStringStorage { getItem(key) { const value = new URL(window.location.href).searchParams.get(key);

  if (value === null) {
    return null;
  }

  if (["true", "false", "null"].includes(value)) {
    return value;
  }

  return JSON.stringify(value);
}

setItem(key, json) {
  const url = new URL(window.location.href);
  const value = JSON.parse(json);

  url.searchParams.set(key, value === null ? "null" : String(value));
  window.history.replaceState(window.history.state, "", url);
}

removeItem(key) {
  const url = new URL(window.location.href);
  url.searchParams.delete(key);
  window.history.replaceState(window.history.state, "", url);
}

}