title: "Dockerizing Legacy .NET Apps: A Practical Guide"
date: 2026-04-20
readingTime: 7 min read
tags: ["Docker", ".NET", "DevOps", "Legacy"]
Migrating legacy .NET Framework applications to containers isn't just about wrapping them in Docker. It's about understanding dependencies, configuration patterns, and preparing for eventual modernization.
This guide covers what I learned containerizing 15+ year-old ERP modules that were never designed for cloud deployment.
Before we dive into how, let's talk about why:
Before touching Docker, document:
## Application: Payroll.Web
**Framework**: .NET Framework 4.7.2
**Type**: ASP.NET Web Forms
**Dependencies**:
- Crystal Reports 13.0.20
- DevExpress 19.1
- SQL Server Native Client 11.0
- COM component: PayrollCalculator.dll
**Configuration**:
- Web.config transforms per environment
- Machine.config dependencies
- IIS application pool settings
- Windows authentication enabled
**External Resources**:
- File share: \\fileserver\payroll\exports
- SMTP server: smtp.internal
- Database: SQL Server 2016
Some things don't containerize easily:
❌ Windows Authentication (Kerberos/NTLM) ❌ COM Components (unless you register them) ❌ GAC Dependencies (Global Assembly Cache) ❌ Machine.config modifications ❌ File system paths (C:\Program Files...)
Decision: Can you refactor these? If not, containerization might not be worth it.
FROM mcr.microsoft.com/dotnet/framework/aspnet:4.8-windowsservercore-ltsc2022
WORKDIR /inetpub/wwwroot
COPY . .
EXPOSE 80
Pros:
Cons:
If you plan to migrate to .NET Core eventually:
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app
# Stage 2: Run
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "YourApp.dll"]
Note: This requires migrating to .NET Core first.
Most packages work fine:
COPY *.sln *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app
For native DLLs:
# Install required Windows features
RUN powershell -Command \
Add-WindowsFeature Web-Asp-Net45, \
Web-Windows-Auth
# Copy native dependencies
COPY libs\* C:\windows\system32\
This is tricky. Options:
COPY PayrollCalculator.dll C:\PayrollCalculator.dll
RUN regasm PayrollCalculator.dll /codebase
// Instead of COM interop, create .NET service
public class PayrollCalculator
{
public decimal Calculate(Employee emp) { ... }
}
// Legacy COM stays on Windows server
// Container calls via gRPC or REST
var result = await _payrollService.CalculateAsync(emp);
Legacy apps often use Web.config transforms:
<!-- Web.Debug.config -->
<configuration xmlns:xdt="Transform">
<appSettings>
<add key="DatabaseConnection"
value="Server=localhost;Database=PayrollDev"
xdt:Transform="SetAttributes" />
</appSettings>
</configuration>
In Docker: Use environment variables instead:
ENV ConnectionStrings__DefaultConnection=Server=db;Database=Payroll;User Id=sa;Password=xxx
// Update code to read from environment
var connectionString = Environment.GetEnvironmentVariable(
"ConnectionStrings__DefaultConnection");
{
"ConnectionStrings": {
"DefaultConnection": "Server=db;Database=Payroll;"
},
"PayrollSettings": {
"BatchSize": 1000,
"RetryCount": 3
}
}
Override with environment variables:
docker run -e PayrollSettings__BatchSize=500 ...
# Legacy: SQL Server Native Client
RUN powershell -Command \
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/?linkid=874124" \
-OutFile "sqlncli.msi"; \
Start-Process msiexec -ArgumentList "/i sqlncli.msi /quiet /norestart IACCEPTSQLNCLILICENSETERMS=YES" -Wait
Better: Use .NET Core's SqlClient:
// No native client needed
using System.Data.SqlClient;
version: '3.8'
services:
payroll-app:
build: .
environment:
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=Payroll;User Id=sa;Password=YourPassword123
depends_on:
- sqlserver
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourPassword123
volumes:
- sql-data:/var/opt/mssql
volumes:
sql-data:
Legacy code often has:
// Don't do this
var path = @"C:\Payroll\Exports\report.pdf";
// Do this instead
var basePath = Environment.GetEnvironmentVariable("EXPORT_PATH")
?? "/app/exports";
var path = Path.Combine(basePath, "report.pdf");
services:
payroll-app:
volumes:
- payroll-exports:/app/exports
- ./config:/app/config:ro # Read-only config
volumes:
payroll-exports:
Legacy apps might depend on:
In Docker: Configure in code or environment:
# Set process model
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
<!-- Legacy Web.config -->
<system.webServer>
<modules>
<add name="CustomAuthModule" type="Auth.CustomAuthModule"/>
</modules>
</system.webServer>
In Docker: Ensure assemblies are copied:
COPY bin/CustomAuthModule.dll /app/
# Build stage
FROM mcr.microsoft.com/dotnet/framework/sdk:4.8 AS build
WORKDIR /source
# Copy solution and projects
COPY *.sln .
COPY src/*.csproj ./src/
RUN nuget restore
# Copy everything else and build
COPY . .
RUN msbuild /p:Configuration=Release /p:DeployOnBuild=true /p:DeployDir=/app
# Run stage
FROM mcr.microsoft.com/dotnet/framework/aspnet:4.8
WORKDIR /inetpub/wwwroot
# Install any additional dependencies
RUN powershell -Command Add-WindowsFeature Web-Windows-Auth
COPY --from=build /app .
EXPOSE 80
# Build
docker build -t payroll-app:legacy .
# Run
docker run -p 8080:80 \
-e ConnectionStrings__DefaultConnection="Server=localhost;Database=Payroll;" \
payroll-app:legacy
# Test
curl http://localhost:8080
version: '3.8'
services:
payroll-app:
build: .
ports:
- "8080:80"
environment:
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=PayrollDev;User Id=sa;Password=DevPassword123
- EXPORT_PATH=/app/exports
volumes:
- payroll-exports:/app/exports
depends_on:
- sqlserver
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=DevPassword123
ports:
- "1433:1433"
volumes:
- sql-data:/var/opt/mssql
volumes:
payroll-exports:
sql-data:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payroll-app
spec:
replicas: 3
selector:
matchLabels:
app: payroll-app
template:
metadata:
labels:
app: payroll-app
spec:
containers:
- name: payroll-app
image: your-registry/payroll-app:1.0.0
ports:
- containerPort: 80
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: db-connection
key: connection-string
resources:
limits:
memory: "2Gi"
cpu: "1000m"
requests:
memory: "1Gi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: payroll-app-service
spec:
selector:
app: payroll-app
ports:
- port: 80
targetPort: 80
type: LoadBalancer
# Add health check to Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD powershell -Command "try { (New-Object Net.WebClient).DownloadString('http://localhost/health') } catch { exit 1 }"
Problem: App tries to write to C:\Program Files...
Solution:
# Create writable directory
RUN mkdir C:\app\data
RUN icacls C:\app\data /grant Everyone:(OI)(CI)F
# Update app to use this path
ENV DATA_PATH=C:\app\data
Problem: SSL certificate validation fails.
Solution (development only):
# Import development certificate
COPY dev-cert.pfx /certs/
RUN certutil -f -p YourPassword -importpfx /certs/dev-cert.pfx
Production: Use proper certificates, don't disable validation.
Problem: Legacy apps weren't designed for container memory limits.
Solution:
# Set memory limits
resources:
limits:
memory: "2Gi"
requests:
memory: "1Gi"
Monitor and adjust based on actual usage.
Containerization is often step one. Step two is modernization:
Run legacy in Docker, new features in .NET Core:
┌─────────────────┐ ┌─────────────────┐
│ Legacy App │ │ New Features │
│ (.NET 4.8) │ │ (.NET 8) │
│ in Docker │ │ in Docker │
└─────────────────┘ └─────────────────┘
│ │
└───────────┬───────────┘
│
┌─────────────┐
│ API Gateway │
└─────────────┘
Rewrite one module at a time:
Eventually, legacy container runs zero traffic. Remove it.
Containerizing legacy apps? Happy to share more war stories. Find me on GitHub or LinkedIn.