Containerizing .NET Applications with Docker: A Practical Guide
Learn how to containerize .NET applications with Docker.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
- ✓Basic knowledge of .NET and C#
- ✓Docker Desktop or Docker Engine installed
- ✓.NET 6 SDK or later
- ✓Familiarity with command line
- Docker packages .NET apps with dependencies into lightweight containers for consistent deployment.
- Use multi-stage builds to reduce image size by separating build and runtime.
- Optimize Dockerfiles by copying only necessary files and using .dockerignore.
- Debug containers using docker logs, exec, and attach commands.
- Follow security best practices: run as non-root user, scan images, use minimal base images.
Imagine you're moving to a new house. Instead of packing each item separately and hoping nothing breaks, you put all your furniture, clothes, and kitchenware into identical moving boxes that stack perfectly. Docker does the same for software: it packages your application with everything it needs (libraries, settings) into a standardized container that runs the same way on any computer, whether it's your laptop, a test server, or the cloud.
In modern software development, ensuring that an application runs consistently across different environments—developer laptops, staging servers, and production—is a constant challenge. The classic 'it works on my machine' problem wastes countless hours and causes production outages. Docker solves this by containerizing applications, packaging them with all dependencies into a lightweight, portable unit.
For .NET developers, Docker is especially powerful. Whether you're building an ASP.NET Core web API, a background service, or a console app, containerization simplifies deployment, scaling, and management. With the advent of .NET's cross-platform capabilities, Docker enables you to run .NET applications on Linux, Windows, or in the cloud with minimal friction.
This tutorial will guide you through the entire process of containerizing a .NET application. You'll learn how to write efficient Dockerfiles, use multi-stage builds to keep images small, debug containers in production, and follow security best practices. We'll also explore a real-world production incident caused by a Docker misconfiguration and how to avoid it. By the end, you'll be able to confidently containerize your .NET apps and deploy them anywhere.
1. Understanding Docker and .NET Integration
Docker is a platform for developing, shipping, and running applications inside lightweight containers. Containers are isolated environments that include the application and its dependencies, ensuring consistent behavior across different systems. For .NET developers, Docker provides a way to package ASP.NET Core applications, console apps, or services into portable images that can run on any Docker host.
The .NET SDK and runtime are available as official Docker images on Docker Hub (e.g., mcr.microsoft.com/dotnet/sdk:6.0 and mcr.microsoft.com/dotnet/aspnet:6.0). These images are optimized for different scenarios: the SDK image includes compilers and tools for building, while the runtime image is smaller and suitable for production.
- Consistency: Same environment from development to production.
- Isolation: Each container has its own filesystem, network, and processes.
- Scalability: Easily scale out by running multiple containers.
- CI/CD Integration: Build and test containers in pipelines.
Before diving in, ensure you have Docker installed (docker --version) and a .NET 6+ project ready.
2. Writing Your First Dockerfile for a .NET App
A Dockerfile is a script that defines how to build a Docker image. For a .NET application, the Dockerfile typically uses a multi-stage build to keep the final image small. Let's create a Dockerfile for a simple ASP.NET Core Web API.
Start with the SDK image to restore dependencies and build the application. Then, use the runtime image to run the published output. This separation ensures that build tools are not included in the final image.
Here's a basic Dockerfile:
```dockerfile # Build stage FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build WORKDIR /app
# Copy csproj and restore dependencies COPY *.csproj ./ RUN dotnet restore
# Copy everything else and build COPY . ./ RUN dotnet publish -c Release -o out
# Runtime stage FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS runtime WORKDIR /app COPY --from=build /app/out . ENTRYPOINT ["dotnet", "MyApp.dll"] ```
FROM ... AS build: Uses the SDK image to build.WORKDIR /app: Sets working directory.COPY *.csproj ./: Copies only the project file to leverage Docker layer caching.RUN dotnet restore: Restores NuGet packages.COPY . ./: Copies the rest of the source code.RUN dotnet publish: Builds and publishes the app.FROM ... AS runtime: Uses the smaller runtime image.COPY --from=build /app/out .: Copies published output from build stage.ENTRYPOINT: Specifies the command to run when container starts.
Build the image with: docker build -t myapp . Run the container: docker run -p 8080:80 myapp
Now your app is accessible at http://localhost:8080.
3. Optimizing Docker Images for .NET
Image size matters for fast deployments and reduced storage costs. Here are key optimization techniques:
- Use .dockerignore: Exclude files not needed in the build context, like node_modules, .git, bin/obj folders. Example:
- ```
- **/.classpath
- **/.dockerignore
- **/.env
- **/.git
- **/.gitignore
- **/.project
- **/.settings
- **/.toolstarget
- **/.vs
- **/.vscode
- */.*proj.user
- */.dbmdl
- */.jfm
- **/bin
- **/charts
- */docker-compose
- */compose
- */Dockerfile
- **/node_modules
- **/npm-debug.log
- **/obj
- **/secrets.dev.yaml
- **/values.dev.yaml
- ```
- Use smaller base images: For runtime, use
aspnet:6.0-alpineinstead ofaspnet:6.0. Alpine-based images are significantly smaller (around 100MB vs 200MB). - Remove unnecessary packages: In the build stage, you can use
--self-contained falseand--no-restoreto minimize output. - Use assembly trimming: For self-contained deployments, enable trimming to remove unused code. Add
<PublishTrimmed>true</PublishTrimmed>to your csproj. - Combine RUN commands: In Dockerfile, combine multiple RUN commands with
&&to reduce layers.
Example optimized Dockerfile: ```dockerfile FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build WORKDIR /app COPY *.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o out --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS runtime WORKDIR /app COPY --from=build /app/out . ENTRYPOINT ["dotnet", "MyApp.dll"] ```
This reduces image size from ~200MB to ~100MB.
4. Debugging .NET Containers in Production
Debugging a containerized application in production requires different tools than local debugging. Here are essential techniques:
1. Viewing Logs: Use docker logs <container> to see stdout/stderr. For real-time logs, add -f flag.
2. Exec into Container: docker exec -it <container> /bin/sh gives you a shell inside the container. From there, you can inspect environment variables, files, and run diagnostic commands.
3. Attach a Debugger: For .NET, you can attach a remote debugger using docker exec and dotnet-dump or dotnet-gcdump for memory analysis. Install the dotnet tools globally: ``bash dotnet tool install --global dotnet-dump dotnet tool install --global dotnet-gcdump ` Then inside the container: `bash dotnet-dump collect --process-id 1 dotnet-gcdump collect --process-id 1 ``
4. Health Checks: Add health checks to your container to automatically detect failures. In Dockerfile, add: ``dockerfile HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD curl -f http://localhost/health || exit 1 ``
5. Use Environment Variables: Pass configuration via environment variables (e.g., -e ASPNETCORE_ENVIRONMENT=Development) to toggle debug logging.
6. Resource Monitoring: Use docker stats <container> to monitor CPU and memory usage in real-time.
Example: Debugging a high memory issue: ``bash docker exec -it mycontainer /bin/sh # Inside container dotnet-gcdump collect -p 1 -o /tmp/dump.gcdump exit docker cp mycontainer:/tmp/dump.gcdump ./dump.gcdump # Analyze locally with dotnet-gcdump report ``
5. Security Best Practices for .NET Docker Containers
Security is critical when running containers in production. Follow these best practices:
1. Run as Non-Root User: By default, containers run as root. Create a non-root user in the Dockerfile: ``dockerfile RUN adduser -u 1000 --disabled-password appuser USER appuser ``
2. Use Minimal Base Images: Prefer aspnet:6.0-alpine over full images to reduce attack surface.
3. Scan Images for Vulnerabilities: Use Docker Scout, Trivy, or Snyk to scan images. Integrate into CI/CD.
4. Avoid Secrets in Images: Never hardcode passwords or connection strings. Use Docker secrets or environment variables at runtime.
5. Keep Images Updated: Regularly rebuild images with latest base images to get security patches.
6. Limit Container Capabilities: Use --cap-drop=ALL and add only necessary capabilities.
7. Use Read-Only Filesystem: Run containers with --read-only flag to prevent modifications.
8. Enable Content Trust: Use Docker Content Trust to verify image signatures.
Example secure Dockerfile: ```dockerfile FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build WORKDIR /app COPY *.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o out
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS runtime WORKDIR /app COPY --from=build /app/out . RUN adduser -u 1000 --disabled-password appuser USER appuser ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 ENTRYPOINT ["dotnet", "MyApp.dll"] ```
Run with: ``bash docker run -d --read-only --cap-drop=ALL --security-opt=no-new-privileges -p 8080:8080 myapp ``
6. Orchestrating .NET Containers with Docker Compose
Docker Compose allows you to define and run multi-container applications. For a .NET app that depends on a database, you can define services in a docker-compose.yml file.
Example: ASP.NET Core API with PostgreSQL.
``yaml version: '3.8' services: api: build: context: . dockerfile: Dockerfile ports: - "5000:80" environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__DefaultConnection=Host=db;Database=mydb;Username=postgres;Password=postgres depends_on: - db db: image: postgres:14-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: mydb volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata: ``
Run with: docker-compose up -d
This starts both the API and database containers. The API can connect to the database using the hostname 'db' (the service name).
For development, you can mount your source code as a volume to enable hot reload: ``yaml services: api: build: context: . dockerfile: Dockerfile.dev volumes: - .:/app environment: - ASPNETCORE_ENVIRONMENT=Development ``
Docker Compose is ideal for local development and testing, but for production, consider Kubernetes or Docker Swarm for orchestration.
7. CI/CD Pipeline Integration for .NET Docker Images
Integrating Docker into your CI/CD pipeline ensures that your images are built, tested, and deployed automatically. Here's an example using GitHub Actions.
Create .github/workflows/docker-build.yml:
```yaml name: Build and Push Docker Image
on: push: branches: [ main ]
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push uses: docker/build-push-action@v4 with: context: . push: true tags: myusername/myapp:latest ```
For .NET-specific optimizations, you can also run unit tests inside the build stage:
``dockerfile FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build WORKDIR /app COPY . . RUN dotnet test --logger trx --results-directory /testresults RUN dotnet publish -c Release -o out ``
Then in the pipeline, you can publish test results.
- Use Docker layer caching to speed up builds.
- Scan images for vulnerabilities before pushing.
- Tag images with commit SHA for traceability.
Example pipeline with caching: ``yaml - name: Build and push uses: docker/build-push-action@v4 with: context: . push: true tags: myusername/myapp:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max ``
The Case of the Bloated Container: A 2GB Image That Crashed Production
- Always use multi-stage builds to separate build and runtime environments.
- Use .dockerignore to exclude files not needed in the final image (e.g., node_modules, .git, test projects).
- Choose the smallest appropriate base image (e.g., aspnet:6.0-alpine instead of sdk).
- Regularly scan images for vulnerabilities and unnecessary packages.
- Monitor image sizes in CI/CD pipelines to catch regressions early.
docker logs <container>docker inspect <container>| File | Command / Code | Purpose |
|---|---|---|
| Program.cs | var builder = WebApplication.CreateBuilder(args); | 1. Understanding Docker and .NET Integration |
| Dockerfile | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build | 2. Writing Your First Dockerfile for a .NET App |
| Dockerfile.optimized | FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build | 3. Optimizing Docker Images for .NET |
| debug.sh | docker exec -it mycontainer /bin/sh | 4. Debugging .NET Containers in Production |
| Dockerfile.secure | FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build | 5. Security Best Practices for .NET Docker Containers |
| docker-compose.yml | version: '3.8' | 6. Orchestrating .NET Containers with Docker Compose |
| .github | name: Build and Push Docker Image | 7. CI/CD Pipeline Integration for .NET Docker Images |
Key takeaways
Common mistakes to avoid
5 patternsUsing a single-stage Dockerfile that includes the SDK in the final image.
Hardcoding connection strings or secrets in the Dockerfile or appsettings.json.
Running the container as root without a non-root user.
Not using .dockerignore and copying entire project directory including node_modules and bin/obj.
Assuming the container can access localhost for a database running on the host.
Interview Questions on This Topic
Explain multi-stage builds in Docker and why they are important for .NET applications.
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
That's ASP.NET. Mark it forged?
6 min read · try the examples if you haven't