When I first started containerizing .NET microservices, my biggest headache was keeping image sizes down and build times fast. Shipping a multi-hundred megabyte SDK environment to production isn’t just wasteful—it’s an unnecessary security risk.

Over time, I settled on a multi-stage Docker setup and GitHub Actions workflow that keeps builds speedy, images lightweight, and production environments secure.

Multi-Stage Dockerfile Strategy

The core strategy is keeping your build tools completely separate from your runtime environment. In stage one, we pull the full .NET SDK to compile and publish the app. In stage two, we pull a minimal ASP.NET runtime image and copy only the compiled output into it.

# Stage 1: Build & Publish
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["Service.csproj", "./"]
RUN dotnet restore "Service.csproj"
COPY . .
RUN dotnet publish "Service.csproj" -c Release -o /app/publish /p:UseAppHost=false

# Stage 2: Runtime Container
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
EXPOSE 8080
COPY --from=build /app/publish .
USER app
ENTRYPOINT ["dotnet", "Service.dll"]

Practical Lessons Learned

  • Never run as root: Always switch to a non-root user like USER app. It takes one line in your Dockerfile, but it prevents container breakout vulnerabilities from handing root access to your host machine.
  • Leverage Docker layer caching: Copy your .csproj file and run dotnet restore before copying the rest of your source code. That way, Docker caches your dependencies so you aren’t re-downloading NuGet packages every time you fix a typo.
  • Automate image scanning in CI/CD: I run scanners like Trivy or Docker Scout directly inside GitHub Actions workflows before pushing to a registry. Catching CVEs in pull requests saves massive headaches later.

Structuring pipelines this way keeps deployment times low, container images slim, and production setups rock-solid.