Lessons Learned Shipping .NET Apps with Docker, Alpine, and Kubernetes
Eelco Los

Eelco Los @eelcolos

About: Building tools & ideas that touch hearts and minds through creativity, innovation & community. Speaking & research-driven dev excite me.

Location:
Netherlands
Joined:
Apr 5, 2024

Lessons Learned Shipping .NET Apps with Docker, Alpine, and Kubernetes

Publish Date: Jun 27
5 2

🛠️ Update (4th of July, 2025):

Microsoft’s official .NET 8+ base images now define a secure numeric user via APP_UID.

If you're using those, prefer USER $APP_UID over manually creating appuser.

I’ve updated the Docker Security section to reflect this recommended approach while keeping the broader appuser pattern for non-Microsoft base images.

I love containerization.

From personal projects running at home to production-grade services, containers have transformed the way I build and ship software. They're lightweight, consistent, and—when used correctly—secure. For local development, I usually prefer to work with full SDKs. But for deployments, I lean heavily on containers, DevContainers, and GitHub Actions.

This post will walk you through a solid workflow for building and running .NET apps in Docker using Alpine, preparing images with CI, and tuning for Kubernetes deployments with realistic resource limits.


🧊 Running .NET in Alpine Containers

Alpine is a super minimal Linux distro that makes for compact Docker images. Microsoft ships Alpine-based variants of .NET like this (at the time of writing this is dotnet 9):

FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine
Enter fullscreen mode Exit fullscreen mode

To have a minimal container is what I feel containerization is really about: work with the OS that is minimal in scope and just focuses on the app execution.
But there's a gotcha—cultural and timezone data isn’t included by default. To make your app work correctly across locales and timezones, add:

RUN apk add --no-cache icu-libs tzdata
Enter fullscreen mode Exit fullscreen mode

➡️ See Andrew Lock’s excellent guide for deeper insights on this issue.


🛡️ Docker Security: Running as Non-Root (And Doing It Right)

One of the most common but overlooked Docker security pitfalls is that containers run as root by default. If someone breaks out of your app process, they’re root inside the container—bad news.

Defining the Non-Root User

Start by creating a lightweight user and group in the image:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
Enter fullscreen mode Exit fullscreen mode
  • -S creates system users/groups (no home directory, no password).
  • This keeps the image small and secure.

🔎 Note on $APP_UID:
The $APP_UID pattern is a Microsoft documented convention introduced in .NET 8+ base images. These images define a numeric non-root user internally and expose its UID via the APP_UID environment variable. This makes it easy to write:

USER $APP_UID

If you're using a non-Microsoft base image (like Alpine or Debian), this variable won't exist unless you define it yourself:

ENV APP_UID=10001
USER $APP_UID

So while the pattern is technically portable, only Microsoft's .NET base images provide it by default. For broader compatibility, the traditional adduser appuser && USER appuser pattern is still widely used and understood. Read more about Microsofts recommendation at https://devblogs.microsoft.com/dotnet/securing-containers-with-rootless/#using-app


Secure File Ownership: Use COPY --chown

I used to rely on fixing permissions like this:

COPY ./build/api .
RUN chown -R appuser:appgroup .
Enter fullscreen mode Exit fullscreen mode

But this isn’t ideal:

  • Adds an extra layer.
  • Slower on large file sets.
  • Messy.

Then I learned to assign ownership directly at copy time:

COPY --chown=appuser:appgroup ./build/api .
Enter fullscreen mode Exit fullscreen mode

This:

  • Instantly assigns correct ownership.
  • Avoids extra RUN chown.
  • Makes your Dockerfile cleaner and more declarative.

Putting It Together

FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

COPY --chown=appuser:appgroup ./build/api .
COPY --chown=appuser:appgroup entrypoint.sh .

USER appuser

ENTRYPOINT ["./entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode
  • The app runs with the least privilege necessary.
  • Files are owned properly the moment they're brought into the image.
  • Clean. Predictable. Secure.

🛠️ CI Builds Artifacts, Docker Just Packages

One of the best things you can do is keep your Dockerfile lean. Not only to avoid compiling inside Docker—it bloats your image and slows builds, but also because of the what docker is for: containerizing your application. Therefore, your app should be ready to be containerized. That is, how I experience Docker to primarily be: the 'containerizer'. So, to build then, use your CI pipeline to build and publish the app, then use Docker to package the output. This will give you inspectable artifacts of the build that.

🔧 GitHub Actions: Build and Upload Artifacts

- name: Build and Publish
  run: |
    dotnet publish -o ${{ env.PUBLISH_FOLDER_NAME }} ${{ inputs.publish-args }}

- name: Upload Build Artifact
  uses: actions/upload-artifact@v4
  with:
    name: ${{ inputs.artifact-name }}
    path: ${{ inputs.project-folder }}/${{ env.PUBLISH_FOLDER_NAME }}
Enter fullscreen mode Exit fullscreen mode

Then in your Docker build step, pull the artifacts back down:

- name: Download artifacts
  run: |
    IFS=',' read -ra artifacts <<< "${{ inputs.download-artifact }}"
    for artifact in "${artifacts[@]}"; do
      mkdir -p "${{ inputs.working-directory }}/build/$artifact"
      gh run download --name "$artifact" --dir "${{ inputs.working-directory }}/build/$artifact"
    done
Enter fullscreen mode Exit fullscreen mode

Finally, build and push the image:

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    file: ${{ env.DOCKERFILE }}
    context: ${{ inputs.working-directory }}
    push: true
    tags: ${{ inputs.container-tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

This artifact-first approach gives you:

  • Reproducibility
  • Cleaner build caching
  • Easy debugging (you can inspect the build output separately)

☸️ Kubernetes + Helm: Resource Limits That Actually Work

Let’s be real—.NET isn’t the smallest kid on the block. You can’t slap a tiny resource limit on it without consequences.

🔍 What Microsoft Recommends for AKS

Microsoft’s official guidance for AKS firmly states:

“Set pod requests and limits on all pods in your YAML manifests. If the AKS cluster uses resource quotas and you don't define these values, your deployment may be rejected.”
— AKS Best Practices (resource requests & limits)

They further caution:

“Pod CPU and memory limits define the maximum amount of CPU and memory a pod can use… avoid setting a pod limit higher than your nodes can support.”
— AKS Best Practices (resource guidelines)

Microsoft also provides a default starting configuration in their examples:

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 250m
    memory: 256Mi
Enter fullscreen mode Exit fullscreen mode

This isn’t a strict minimum—but it is a realistic baseline that balances scheduling, performance, and cost .


My .NET-Focused Configuration

Here’s the setup that consistently works for .NET workloads I test:

resources:
  requests:
    cpu: 10m
    memory: 20Mi
  limits:
    cpu: 100m
    memory: 175Mi
Enter fullscreen mode Exit fullscreen mode

⚠️ While .NET can technically run with ~125 Mi memory, in practice this leads to:

  • Sluggish cold starts
  • Failing health probes
  • Garbage collector thrash

Pushing memory to 175 Mi ensures decent startup times and runtime stability.


⚖️ TL;DR Recommendations


🎯 Final Thoughts

  • Use small sized base images, like Alpine, but patch it with you needs (ie: icu-libs and tzdata)
  • Run as a non-root user inside your Docker containers
  • Use CI to build the app, and let Docker just package it
  • Tune your K8s Helm charts to keep .NETs footprint small, but still responsive under pressure of your required workload

Containers are amazing, but they're even better when treated with care. With these practices, you’ll ship faster, safer, and smarter—whether it's production, staging, or even your home lab.


Got questions or tweaks to share? Drop them in the comments—I'd love to hear your workflow!

Comments 2 total

  • Rasmus Bækgaard
    Rasmus BækgaardJul 3, 2025

    That user you create.

    USER $APP_UID

    Shouldn't that do the trick?
    Source: devblogs.microsoft.com/dotnet/secu...

    • Eelco Los
      Eelco LosJul 4, 2025

      Hey @rasmus_bkgaard_7ce1749e6 you're right, this is another way to have the container run as non-root. As shown in your source, both options are being suggested. I see this would be Microsofts recommended way.

      That being said, it is a feature documented for Microsoft containers only. As this is a dotnet post, I'll add a paragraph referring to it

Add comment