Migrating to Node.js Chainguard Containers
Learn how to migrate Node.js applications to Chainguard Containers for reduced vulnerabilities, smaller image sizes, and …
Chainguard’s .NET container images provide a security-hardened foundation for building and running applications with significantly fewer vulnerabilities than .NET images provided by Microsoft. Chainguard’s .NET container images maintain full .NET compatibility while dramatically reducing the attack surface.
This guide demonstrates migrating a .NET application from Microsoft’s official images to Chainguard’s .NET container images by comparing two nearly identical versions of an application side-by-side. This guide also highlights concrete examples of the security improvements resulting from migrating to Chainguard Containers.
This tutorial uses the publicly available .NET container images from Chainguard’s free Starter tier of images. You don’t need special access or permissions to use these images.
To follow along, you must have Docker installed on your local machine. If you don’t have Docker installed, you can download and install it from the official Docker website. Optionally, you can install Grype to scan container images for vulnerabilities and compare the security posture of different base images.
This step involves downloading the demo application code to your local machine. To prevent the application files from remaining on your system, navigate to a temporary directory like /tmp/:
cd /tmp/Most systems will automatically delete the /tmp/ directory’s contents the next time it shuts down or reboots.
The code that comprises this demo application is hosted in a public GitHub repository managed by Chainguard. Pull down the example application files from GitHub with the following command:
git clone --sparse https://github.com/chainguard-dev/edu-images-demos.gitBecause this guide’s demo application code is stored in a repository with other examples, we don’t need to pull down every file from this repository. For this reason, this command includes the --sparse option. This initializes a sparse-checkout file, causing the working directory to contain only the files in the root of the repository until the sparse-checkout configuration is modified.
Navigate into this new directory:
cd edu-images-demos/To retrieve the files you need for this tutorial’s sample application, run the following git command:
git sparse-checkout set dotnetThis modifies the sparse-checkout configuration initialized in the previous git clone command so that the checkout only consists of the repo’s dotnet directory.
Navigate into the dotnet directory:
cd dotnet/Here you’ll find two subdirectories:
not-linky/: Contains an application that uses Microsoft’s official .NET imageslinky/: Contains a nearly identical application, but with a Dockerfile that uses Chainguard ContainersThe demo application in both directories is a .NET console program that displays runtime and system information, including:
The only significant difference between the two is that the application in the not-linky directory prints an ASCII art banner reading dotnet, while the linky directory’s application prints ASCII art of Linky, Chainguard’s octopus mascot. Otherwise, both directories’ Program.cs and dotnetapp.csproj files are identical.
This sample application is based on Microsoft’s dotnet-runtimeinfo sample and demonstrates a typical .NET console application that could be containerized for various use cases.
In the following sections, we’ll build and compare both versions of the application.
Let’s start by building and running the application using the .NET container images provided by Microsoft to establish a baseline for comparison.
Navigate to the not-linky directory:
cd not-linkyYou can inspect the Dockerfile to see how it’s structured:
cat Dockerfile# Learn about building .NET container images:
# https://github.com/dotnet/dotnet-docker/blob/main/samples/README.md
ARG IMAGE_REGISTRY="mcr.microsoft.com"
FROM --platform=$BUILDPLATFORM ${IMAGE_REGISTRY}/dotnet/sdk:9.0 AS build
ARG TARGETARCH
WORKDIR /source
# Copy project file and restore as distinct layers
COPY --link *.csproj .
RUN dotnet restore -a $TARGETARCH
# Copy source code and publish app
COPY --link . .
RUN dotnet publish -a $TARGETARCH --no-restore -o /app
# Runtime stage
FROM ${IMAGE_REGISTRY}/dotnet/runtime:9.0
WORKDIR /app
COPY --link --from=build /app .
USER $APP_UID
ENTRYPOINT ["./dotnetapp"]This Dockerfile uses a multi-stage build pattern:
mcr.microsoft.com/dotnet/sdk:9.0 to compile the applicationmcr.microsoft.com/dotnet/runtime:9.0 for the final imageUSER $APP_UIDBuild the container image:
docker build -t dotnet-notlinky .This docker build command builds a container image named dotnet-notlinky.
Run the application:
docker run --rm dotnet-notlinky 42
42 ,d ,d
42 42 42
,adPPYb,42 ,adPPYba, MM42MMM 8b,dPPYba, ,adPPYba, MM42MMM
a8" `Y42 a8" "8a 42 42P' `"8a a8P_____42 42
8b 42 8b d8 42 42 42 8PP!!!!!!! 42
"8a, ,d42 "8a, ,a8" 42, 42 42 "8b, ,aa 42,
`"8bbdP"Y8 `"YbbdP"' "Y428 42 42 `"Ybbd8"' "Y428
OSArchitecture: X64
OSDescription: Debian GNU/Linux 12 (bookworm)
FrameworkDescription: .NET 9.0.10
UserName: app
HostName : 33b915776b75
ProcessorCount: 16
TotalAvailableMemoryBytes: 16472768512 (15.34 GiB)Running the container returns system information, including a stylized ASCII art banner followed by runtime details.
Now let’s build the application with Chainguard’s .NET container images. Navigate to the linky directory:
cd ../linkyThe main difference between the two application directories is their respective Dockerfiles. Inspect the linky directory’s Dockerfile to understand the differences:
cat Dockerfile# Learn about building .NET container images:
# https://github.com/dotnet/dotnet-docker/blob/main/samples/README.md
ARG IMAGE_REGISTRY="cgr.dev/chainguard"
FROM --platform=$BUILDPLATFORM ${IMAGE_REGISTRY}/dotnet-sdk:latest-dev AS build
ARG TARGETARCH
WORKDIR /source
# Copy project file and restore as distinct layers
COPY --link *.csproj .
# switch to root user in order to do restore
USER 0
RUN dotnet restore -a $TARGETARCH
# Copy source code and publish app
COPY --link . .
RUN dotnet publish -a $TARGETARCH --no-restore -o /app
# Runtime stage
FROM ${IMAGE_REGISTRY}/aspnet-runtime:latest
WORKDIR /app
COPY --link --from=build /app .
ENTRYPOINT ["./dotnetapp"]Like the Dockerfile in the not-linky directory, this Dockerfile uses a multi-stage build. However, there are a few key differences between the two Dockerfiles. To find these differences, run the following diff command:
git diff --no-index -U10000 ../not-linky/Dockerfile Dockerfilediff --git a/../not-linky/Dockerfile b/Dockerfile
index ce4d06d..cdd6a64 100644
--- a/../not-linky/Dockerfile
+++ b/Dockerfile
@@ -1,21 +1,22 @@
# Learn about building .NET container images:
# https://github.com/dotnet/dotnet-docker/blob/main/samples/README.md
-ARG IMAGE_REGISTRY="mcr.microsoft.com"
-FROM --platform=$BUILDPLATFORM ${IMAGE_REGISTRY}/dotnet/sdk:9.0 AS build
+ARG IMAGE_REGISTRY="cgr.dev/chainguard"
+FROM --platform=$BUILDPLATFORM ${IMAGE_REGISTRY}/dotnet-sdk:latest-dev AS build
ARG TARGETARCH
WORKDIR /source
# Copy project file and restore as distinct layers
COPY --link *.csproj .
+# switch to root user in order to do restore
+USER 0
RUN dotnet restore -a $TARGETARCH
# Copy source code and publish app
COPY --link . .
RUN dotnet publish -a $TARGETARCH --no-restore -o /app
# Runtime stage
-FROM ${IMAGE_REGISTRY}/dotnet/runtime:9.0
+FROM ${IMAGE_REGISTRY}/aspnet-runtime:latest
WORKDIR /app
COPY --link --from=build /app .
-USER $APP_UID
ENTRYPOINT ["./dotnetapp"]This diff output highlights the following differences:
linky directory’s Dockerfile uses cgr.dev/chainguard/dotnet-sdk:latest-dev with the -dev variant for build tools instead of mcr.microsoft.com/dotnet/sdk:9.0USER 0 (root) during the restore phase, as this is required for package operations in Chainguard’s security modellinky Dockerfile uses cgr.dev/chainguard/aspnet-runtime:latest which runs as non-root by default, meaning it doesn’t require explicit USER directivesBuild a container image with this Dockerfile:
docker build -t dotnet-linky .Here, the docker build command names the image dotnet-linky.
Run the application:
docker run --rm dotnet-linky******************************************************************************++++++++++++++++++++++
*************************************************************************+++++++++++++++++++++++++++
*************************************************************************+++++++++++++++++++++++++++
************************************************++++******************++++++++++++++++++++++++++++++
******************************************+-:..........:-+**********++++++++++++++++++++++++++++++++
***************************************+:.....:-=++=-:.....:=****+++++++++++++++++++++++++++++++++++
*************************************-....=**************=....-+*+++++++++++++++++++++++++++++++++++
***********************************=...:***+.....:*********+:...=+++++++++++++++++++++++++++++++++++
**********************************:..:****-......-************:..:++++++++++++++++++++++++++++++++++
********************************+...=**-:*+....-***************=...=++++++++++++++++++++++++++++++++
*******************************+...+*=...+**********************+...=+++++++++++++++++++++++++++++++
******************************+...***+:-*************************+...=++++++++++++++++++++++++++++++
*****************************+...*********************************+...++++++++++++++++++++++++++++++
*****************************:..+**********************************=..:+++++++++++++++++++++++++++++
****************************=..-***********************************+:..=++++++++++++++++++++++++++++
***************************+...**********-....:+**********-....:+**++...++++++++++++++++++++++++++++
***************************=..:********=..=+*+..:*******+..=+++:.:+++:..=+++++++++++++++++++++++++++
***************************=..-********..****:...+*****+..+**+-...=++-..=+++++++++++++++++++++++++++
***************************=..-********..+*****:.+*****+..+**+*+:.=++-..-+++++++++++++++++++++++++++
***************************=..:********=..-++=..-*******+..-===..-+++:..=+++++++++++++++++++++++++++
***************************+...+*********=:..:-+*********+=:..:-+++++...++++++++++++++++++++++++++++
****************************+...*************************+++++++++++...=++++++++++++++++++++++++++++
************************+=-:....:**********************++++++++++++:....:-=+++++++++++++++++++++++++
*******************+=:.......:-+********************+++++++++++++++++-:.... ..:-=+++++++++++++++++++
****************+:.....:=+************************++++++++++++++++++++++++=-:.....:=++++++++++++++++
***************-...=****************************++++++++++++++++++++++++++++++++-...-+++++++++++++++
**************+..:*****************************+++++++++++++++++++++++++++++++++++...+++++++++++++++
***************-..:**************************+++++++++++++++++++++++++++++++++++=:..:+++++++++++++++
****************-.. ....::::-*************++++++++++++++=++++++++++++++::::........-++++++++++++++++
******************+=:......=**************=-++++++++++++-=++++++++++++++-......:-+++++++++++++++++++
***********************=..-*************+-.-++++++++++++:.-++++++++++++++-..-+++++++++++++++++++++++
******************+++++=..-***********+=....++++++++++++....-++++++++++++:..=+++++++++++++++++++++++
*****************+++++*+:...-++**++=-.......-++++++++++-.......:=+++++=-...:++++++++++++++++++++++++
***************++*++++++++:...........:=++:..-++++++++-..:++=:...........:=+++++++++++++++++++++++++
*************++++++++++++++++======++++++++:...=++++=...:+++++++===--==+++++++++++++++++++++++++++++
**********++*+++++++++++++++++++++++++++++++=..........=++++++++++++++++++++++++++++++++++++++++++++
**********+++++++++++++++++++++++++++++++++++++-::::=+++++++++++++++++++++++++++++++++++++++++++++++
********++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
*****+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
OSArchitecture: X64
OSDescription: Wolfi
FrameworkDescription: .NET 9.0.10
UserName: nonroot
HostName : 1914681fe4cb
ProcessorCount: 16
TotalAvailableMemoryBytes: 16472768512 (15.34 GiB)This output is similar to what was returned by running the dotnet-notlinky image, albeit with different ASCII art. Otherwise, the two applications function identically.
If you have Grype installed, you can scan both the container images you’ve built for vulnerabilities. Start by scanning the dotnet-notlinky image:
grype dotnet-notlinky. . .
[0014] INFO found 72 vulnerability matches across 262 packages
[0014] DEBUG ├── fixed: 0
[0014] DEBUG ├── ignored: 0 (due to user-provided rule)
[0014] DEBUG ├── dropped: 0 (due to hard-coded correction)
[0014] DEBUG └── matched: 72
[0014] DEBUG ├── unknown severity: 0
[0014] DEBUG ├── negligible: 48
[0014] DEBUG ├── low: 6
[0014] DEBUG ├── medium: 12
[0014] DEBUG ├── high: 5
[0014] DEBUG └── critical: 1
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
apt 2.6.1 deb CVE-2011-3374 Negligible
bsdutils 1:2.38.1-5+deb12u3 deb CVE-2022-0563 Negligible
. . .This portion of the grype output shows that the dotnet-notlinky container image has 72 vulnerabilities, including five high-severity vulnerabilities and one critical vulnerability.
Next, scan the Chainguard-based image:
grype dotnet-linky. . .
[0002] INFO found 0 vulnerability matches across 333 packages
[0002] DEBUG ├── fixed: 0
[0002] DEBUG ├── ignored: 0 (due to user-provided rule)
[0002] DEBUG ├── dropped: 0 (due to hard-coded correction)
[0002] DEBUG └── matched: 0
[0002] DEBUG ├── unknown severity: 0
[0002] DEBUG ├── negligible: 0
[0002] DEBUG ├── low: 0
[0002] DEBUG ├── medium: 0
[0002] DEBUG ├── high: 0
[0002] DEBUG └── critical: 0
No vulnerabilities foundAs this output shows, grype didn’t find any vulnerabilities in the dotnet-linky image.
You can compare the sizes of the two container images with docker:
docker image list | grep dotnet-dotnet-linky latest 046ae0be8f9b 5 minutes ago 171MB
dotnet-notlinky latest ab2b43e44aca 7 minutes ago 199MBThis output shows that the dotnet-linky container image is significantly smaller than the dotnet-notlinky image.
Note: The command outputs shown in these examples were validated at the time of this writing. Over time, the number of vulnerabilities in either image is likely to change, though you can always expect the Chainguard-based image to contain fewer vulnerabilities.
When migrating a .NET application to use Chainguard Containers, keep the following considerations in mind:
mcr.microsoft.com to cgr.dev/chainguard (or to your organization’s private repository within the Chainguard registry, as in cgr.dev/example.com)restore operations: Chainguard’s security model requires explicit root user switching for dotnet restore or apk operations:USER 0
RUN dotnet restore -a $TARGETARCHThe runtime stage automatically runs as non-root, so you don’t need to add USER directives.
When migrating your .NET applications to Chainguard Containers, remember to use the dotnet-sdk:latest-dev container image for the build stage, and to choose the appropriate runtime based on the type of application:
cgr.dev/chainguard/aspnet-runtime:latest for ASP.NET applicationscgr.dev/chainguard/dotnet-runtime:latest for console applicationsMigrating .NET applications from Microsoft’s official images to Chainguard’s container images provides significant security benefits with minimal code changes. The multi-stage build pattern remains the same, with the primary differences being:
These small changes result in containerized applications with few-to-zero vulnerabilities and smaller image sizes.
For detailed information about Chainguard’s .NET container images and additional configuration options, refer to the following resources:
Last updated: 2025-11-05 00:00