.NET Framework 4.8 + IIS + SQL Server: A Modern Development and Deployment Workflow for Legacy Apps
Plenty of revenue-critical apps are still running on .NET Framework 4.8, IIS, and SQL Server. The stack is supported, well-understood, and not going anywhere — Microsoft has committed to .NET Framework 4.8 support for the lifetime of Windows Server it ships with, which puts the realistic end-of-life past 2032. The problem isn’t that the stack is dying. The problem is that the tooling around it has aged unevenly, and the modern developer-experience patterns we expect from .NET 8 or Node.js apps don’t translate directly.
This post is the playbook I wish I’d had when I last inherited a 4.8 / IIS / SQL Server app and was told “set up a dev environment, don’t break production, don’t break shared test.” It covers workstation setup, isolated local development (including Docker with Windows containers), per-developer databases, web.config transforms that don’t make you cry, a CI build pipeline, deployment patterns including Web Deploy and Octopus, and the modernization on-ramp when you’re ready.
What You’re Actually Dealing With
Before we talk about workflow, let’s be precise about the constraints, because they drive most of the decisions downstream.
.NET Framework 4.8 is Windows-only. This isn’t .NET (formerly .NET Core), which is cross-platform. Framework 4.8 calls into Windows-specific runtime libraries and cannot run on Linux containers. Anywhere you see “dotnet on Linux,” it’s the modern .NET, not Framework. If your app references System.Web, depends on classic WCF, or hosts in IIS via aspnet_isapi, you are firmly in Windows-only territory.
IIS is Windows-only. There is no portable IIS. The closest Linux analogs are Nginx or Apache, but they cannot host ASP.NET Framework apps. You can sometimes run an ASP.NET Framework app under Kestrel on Linux via Mono, but you should not, and we will not discuss it further.
SQL Server is cross-platform. SQL Server 2017+ runs natively on Linux. The Linux container image (mcr.microsoft.com/mssql/server) is the modern way to run a local SQL Server, and it’s the single biggest quality-of-life improvement available to this stack. This matters because it means your dev environment can be: Windows for the web app, Linux container for the database. You can do dev on a Windows laptop without installing SQL Server on the host.
Docker Desktop on Windows has two modes. Windows containers and Linux containers. You can only run one at a time, and you switch via the Docker Desktop tray icon. For this stack you need both — Windows containers for .NET Framework 4.8, Linux containers for SQL Server — and you’ll need to switch between them or use Docker Desktop’s “Windows containers can host Linux containers via LCOW” experimental feature. The practical path most teams take is to run SQL Server directly on the host or in WSL2 to avoid the mode-switching dance.
These four facts dictate everything that follows.
Workstation Setup
Here’s a workstation setup that will let you do everything in this post.
Operating system
Windows 11 Pro or Windows 10 Pro (22H2). Pro is required because Docker Desktop and Hyper-V need it. Home will fight you. If you must use Home, factor in the time to either upgrade or accept a worse experience.
Server-OS variants (Windows Server 2022/2025) also work and are sometimes used for dev VMs, but the daily-driver case is Win11 Pro.
Visual Studio
Visual Studio 2022 Community, Professional, or Enterprise. The 17.8+ versions have the best .NET Framework 4.8 tooling and ship with the targeting packs you need.
Workloads to install:
- ASP.NET and web development
- .NET desktop development (for older WPF/WinForms parts of the app, if any)
- Data storage and processing (SSDT, SQL Server Data Tools)
- .NET Framework 4.8 targeting pack (under Individual Components)
- MSBuild (included by default)
- Web Deploy publishing tools (under Individual Components)
VS Code is fine as a secondary editor but does not replace Visual Studio for this stack. Build, debug, profile, and SSDT integration all assume VS.
SDKs and runtimes
- .NET Framework 4.8 Developer Pack — should come with VS, install separately if it didn’t
- .NET 8 SDK — needed only if you’re integrating modern .NET projects or use
dotnetCLI utilities likedotnet-format - Windows SDK — VS will install the right one
SQL Server
You have three sensible options, in order of how much you’ll actually like them:
- SQL Server in a Docker Linux container (recommended for most cases)
- SQL Server Developer Edition installed on the host
- LocalDB
LocalDB is fine for a single developer working alone but doesn’t support all of SQL Server’s features (no SQL Agent, no full-text search in older versions, no cross-database queries with full fidelity). It tends to lie to you about what production will do.
Developer Edition is free, fully featured, and behaves identically to Enterprise Edition. It just installs to your host and runs as a service. Pick this if you don’t want to deal with containers.
The Linux container is what I run today. It starts in seconds, you can run multiple instances on different ports, and tearing it down is docker rm. We’ll set this up properly later in the post.
SQL Server Management Studio (SSMS)
Install the latest. It connects to any of the SQL Server options above.
IIS or IIS Express
For day-to-day F5 debugging in Visual Studio, IIS Express is the default and it’s fine. It’s a stripped-down IIS that runs in user-mode and only listens while VS is running.
For tests that need IIS-specific behavior (Windows Auth, certain ISAPI modules, app pool recycling), install IIS itself via Windows Features. Use it sparingly — its presence on your dev box makes the environment less reproducible.
Git and the rest
- Git for Windows (or use the bundled Git in VS)
- A terminal you’ll actually use — Windows Terminal with PowerShell 7 is the modern default
- Docker Desktop for Windows
- NuGet CLI (
nuget.exeon PATH) for any out-of-VS package work
Repo Layout and Source Control
Get the repo right and the rest gets easier. A workable layout for a single .NET Framework 4.8 web app:
/repo-root
/src
/MyCompany.WebApp # Web project (.csproj)
/MyCompany.WebApp.Core # Business logic
/MyCompany.WebApp.Data # Data access, EF6 context
/MyCompany.WebApp.Tests # Unit tests
/db
/schema # Migration scripts or DACPAC project
/seed # Reference data
/docker
Dockerfile.sqlserver
init.sql
/build
build.ps1 # MSBuild wrapper
package.ps1 # MSDeploy package builder
/deploy
deploy.ps1
parameters.xml # Per-env parameters for MSDeploy
/docker
Dockerfile.web # ASP.NET Framework 4.8 image
docker-compose.yml
docker-compose.override.yml # Per-dev local override (gitignored)
/.github/workflows # CI definitions
ci.yml
/.config
dotnet-tools.json
Directory.Build.props # Solution-wide MSBuild settings
Directory.Packages.props # Central package management (NuGet 6.2+)
global.json
README.md
Key points:
- Web app at
/src/MyCompany.WebApp. Don’t put it at the repo root. Future you will thank you when you need to add a worker service or a microservice. - Central package management.
Directory.Packages.propskeeps NuGet versions in one place. This was added in NuGet 6.2 and works fine with Framework projects. Directory.Build.propslets you set things likeTreatWarningsAsErrors, deterministic builds, and source link without editing every .csproj.- Configuration files are not in source control verbatim. We’ll get to that.
.gitignore essentials
Beyond the standard VS .gitignore, make sure these are ignored:
**/web.config.*.user
**/appsettings.*.user.json
**/secrets.json
docker-compose.override.yml
/src/**/bin/
/src/**/obj/
/packages/
*.suo
*.user
The docker-compose.override.yml exclusion is important — that’s where per-developer overrides live without polluting the shared compose file.
Configuration: The web.config Problem
The single biggest source of friction in this stack is configuration. Connection strings, app settings, feature flags — they all live in web.config, and managing one file across dev / test / staging / prod has burned every team that’s ever touched it.
There are three approaches in active use. They are not mutually exclusive.
Approach 1: web.config transforms (built-in)
Visual Studio ships with XML Document Transform (XDT) support. You have a base web.config and per-configuration transforms:
web.config # Base
web.Debug.config # Local development
web.Release.config # Production-shaped
web.Staging.config # Staging environment
A transform looks like:
|
|
Transforms are applied at publish time, not build time. Out of the box, F5 debug builds don’t transform. The SlowCheetah Visual Studio extension fixes this for all non-publish builds and is the single most useful tool for this stack — install it.
Transforms work well for the differences that don’t contain secrets. They fall apart when you have per-developer differences or anything secret.
Approach 2: External config files
web.config supports the configSource attribute on most sections, which lets you point at an external file:
|
|
You then have connectionStrings.config checked in with a placeholder, and connectionStrings.local.config in .gitignore. Each developer copies the placeholder to .local and points it at their local SQL Server.
The launch profile (in your csproj or via an MSBuild target) copies the right .config based on a build property:
|
|
This pattern is uglier than transforms but lets developers diverge without conflicts.
Approach 3: Environment variables
ASP.NET Framework can read environment variables in code, and you can override appSettings from env vars with a one-time wiring at app startup:
|
|
This is the pattern that maps cleanly to Docker (environment: in compose) and to modern deploy systems. It’s worth adopting even on a legacy app because it makes containerized deployment dramatically easier.
Recommended setup
Use all three, in this order of preference for any given setting:
- Environment variables for anything Docker/deploy should override
- External config files (
configSource) for connection strings and per-developer settings - web.config transforms for the small set of structural changes between environments (debug/release compilation, error pages, custom errors)
And never commit production secrets to source control. Use Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault for those.
Per-Developer Database Isolation
The single most common cause of “dev broke test” is two developers stomping on the same database. Solve this once and never deal with it again.
The model
Each developer gets their own database, named with their identity:
MyApp_jefffor meMyApp_alicefor AliceMyApp_bobfor Bob
Connection strings are templated. The local-config pattern above lets each developer point at their own database without conflicts:
|
|
Schema management
You need an automated, repeatable way to bring an empty database up to the current schema. The three serious options:
| Tool | What it is | Best for |
|---|---|---|
| DbUp | Script-based, applies SQL scripts in order, tracks what’s been run | Most apps; simplest model that works |
| Entity Framework 6 Migrations | Code-first migrations | EF6-heavy apps where the model drives schema |
| SSDT / DACPAC | Declarative schema project, generates diff scripts | Schema-first apps; teams that prefer declarative |
For brownfield 4.8 apps, DbUp is the pragmatic choice unless you already have EF migrations or a SSDT project. It’s a small NuGet package with a simple model: SQL scripts in a folder, applied in alphanumeric order, tracked in a SchemaVersions table.
A DbUp runner project:
|
|
Scripts are embedded resources with naming like:
/Scripts
Script0001_InitialSchema.sql
Script0002_AddCustomerTable.sql
Script0003_AddOrderIndex.sql
A developer running locally executes the migrator against their own database. Every CI run executes it against an empty ephemeral database. Production deploys execute it against production. Same code, same scripts, predictable outcomes.
Seed data
Keep seed data separate from schema. A /db/seed folder of idempotent scripts that insert reference data (lookup tables, default users, feature flags) lets you bootstrap a development database from scratch in under a minute.
Refreshing dev databases from production
Sometimes you genuinely need real-shaped data to reproduce a bug. The standard pattern:
- Take a production backup (
.bakfile) - Restore it to a non-production server (never restore directly to a dev workstation across the network — it’s slow and may carry sensitive data)
- Run a data sanitization pass that masks PII, scrambles emails, resets passwords
- Take a backup of the sanitized DB
- Make that backup available for developers to restore locally
Tools like Redgate Data Masker or your own SQL scripts handle the sanitization. Whatever you do, never give every developer unfettered production data. Audit who has access to a sanitized copy.
Local Development with Docker
Here’s the realistic, working Docker setup. We use:
- Windows container for the ASP.NET Framework 4.8 web app
- Linux container for SQL Server (saves time and disk vs Windows SQL Server containers)
This requires Docker Desktop in Windows containers mode with “Switch to Linux containers” disabled, plus enabling WSL2 integration so Linux containers run alongside Windows ones on the same daemon. This works in recent Docker Desktop versions (4.30+). If you can’t get the mixed mode working, the fallback is to run SQL Server on the host or in WSL2 directly.
Dockerfile for the web app
|
|
The base image is ~8GB. That’s the cost of doing business with Windows containers; pulled once, cached forever.
Docker Compose
|
|
A .env file at the same level provides the SA password:
SA_PASSWORD=YourDev_Pwd1!
(Add .env to .gitignore.)
Per-developer overrides
docker-compose.override.yml is loaded automatically by Docker Compose if present. Use it for things that differ per developer:
|
|
Running it
|
|
Browse to http://localhost:8080. SSMS or Azure Data Studio can connect to localhost,1433 with sa and the password from your .env.
Hot reload reality
There is no good hot-reload story for .NET Framework 4.8 in Docker. The fastest inner loop is:
- F5 in Visual Studio against IIS Express — fastest, but doesn’t test the IIS environment
- F5 in Visual Studio against IIS on the host — slower start, exercises IIS
- Docker compose with the web image — slowest, but matches production most closely
Use the Docker path for integration tests and for verifying release builds, not for every code change.
Build Pipeline
The build produces two artifacts:
- The web app as a Web Deploy package — a
.zipcontaining the published site plus a parameters file - The database migrator — either an executable that applies scripts, or a DACPAC
MSBuild from the command line
|
|
The web deploy package can be deployed by msdeploy.exe to any IIS server with the right credentials. We’ll get to the deploy side next.
parameters.xml for environment substitution
When you build a Web Deploy package, parameters defined in parameters.xml become the per-deploy substitution points:
|
|
This lets you build once and deploy the same artifact to dev / staging / prod with different connection strings.
GitHub Actions
GitHub-hosted Windows runners (windows-2022) include MSBuild, NuGet, and the .NET Framework 4.8 targeting pack. A working CI workflow:
|
|
For private source, set up a self-hosted Windows runner on a VM you control. The runner agent is a small service; the build environment (VS Build Tools, Windows SDK, .NET Framework targeting packs) needs to be installed once and kept patched.
Azure DevOps
Azure DevOps Pipelines remains the most polished CI for this stack. Microsoft-hosted windows-2022 agents work the same as GitHub’s. The YAML is similar; the VSBuild@1 task wraps msbuild with sensible defaults.
Deployment
You have artifacts. Now you need to get them onto an IIS server reliably.
Web Deploy (MSDeploy)
The canonical deploy mechanism. The target IIS server runs the Web Deploy service (MsDepSvc.exe), authenticates the deploy via Windows auth or a deploy-specific user, and applies the package atomically.
A deploy from the command line:
|
|
Set up:
- Install IIS Web Deploy 3.6+ on the target server with the management service enabled
- Create a deploy user with permissions to the IIS site (typically a service account)
- Open port 8172 from your CI runner to the target
- Store credentials in your CI’s secret manager (GitHub Secrets, Azure DevOps Variable Groups)
Web Deploy is fiddly to set up the first time and rock-solid once it works. The atomic-publish behavior (App_Offline.htm during the swap) is built in.
Octopus Deploy
Octopus is the de facto deployment orchestrator for .NET Framework apps and is worth the license fee for anything beyond a single environment. It wraps Web Deploy, gives you a UI for environments and releases, handles approvals and rollbacks, and manages variables per environment.
The pattern:
- CI builds the package and pushes it to Octopus’s built-in artifact repository
- Octopus creates a release tied to the package version
- Releases progress through environments (Dev → Test → Staging → Prod) with optional approval gates
- Each environment has its own variable set (connection strings, feature flags, app pool names)
- Octopus handles the IIS deploy via Tentacle agents installed on target servers
The closest open-source analogs are Jenkins + custom scripts, or GitHub Actions environments + reusable workflows. Octopus is genuinely better than either for this stack. If you’re doing more than one or two deploys a week, it pays for itself.
Database deployment
Deploy the database before the web app. Same artifact pipeline:
|
|
The migrator should be idempotent — running it twice in a row should be a no-op. DbUp handles this by tracking applied scripts in a SchemaVersions table.
Schema changes that aren’t backwards-compatible (renaming a column the app reads, dropping a table) need the expand/contract pattern:
- Expand: Add the new column, keep the old. Deploy.
- Backfill: Migrate data from old to new. Deploy.
- Switch: Make the app read/write the new column. Deploy.
- Contract: Drop the old column. Deploy.
Four deploys instead of one, but no downtime. This is the same expand/contract pattern modern systems use.
Test Environments
A “shared test environment” is fine for end-to-end integration and UAT. It is not fine as the only non-production environment, because every developer’s feature branch contends for it. Two complementary patterns:
Per-pull-request environments
CI builds a deploy package on every PR. A pipeline step deploys it to a per-PR site on a dedicated dev IIS server:
pr-123.dev.example.comfor PR #123- Each PR gets its own IIS site, its own SQL database, and its own DNS name
- A scheduled job tears down PR environments after the PR is merged or closed
The IIS site creation script:
|
|
Plus a SQL database per PR:
|
|
The whole PR environment teardown is the inverse.
Branch databases for developers
Even simpler: when a developer creates a feature branch, they create a database named after the branch (MyApp_jeff_checkout_redesign). Their local environment points at it. When they merge the branch, they drop the database.
This costs nothing extra and gives every meaningful unit of work its own data sandbox.
Logging, Monitoring, and Maintenance
Once you can build, deploy, and isolate environments, the last category is operational sanity.
Structured logging
The default ASP.NET Framework logging is trace listeners and System.Diagnostics, neither of which is useful in 2026. Use Serilog or NLog with a structured sink.
A reasonable Serilog setup:
|
|
Sink to a local rolling file and ship to a central store — Seq, Elasticsearch via Filebeat, or Application Insights.
Health endpoints
Every app should expose a /health endpoint that:
- Returns 200 OK if the app is healthy
- Checks SQL Server connectivity
- Checks any downstream dependencies
- Returns JSON with detailed status
Wire it into your load balancer’s health checks and your monitoring system.
Application Insights
For .NET Framework apps, Application Insights still works well. The SDK is a NuGet package; instrumentation is mostly automatic for ASP.NET, SQL, and outbound HTTP. The trade-off is cost — at high traffic it gets expensive — and the lock-in to Azure.
IIS-specific observability
Things to monitor that are IIS-specific:
- Application pool recycles — frequent recycles indicate memory leaks
- Request queue length — a queue building up means threads are blocked
- CPU per app pool — runaway requests pin a worker process
- 2xx/3xx/4xx/5xx rates by status code
IIS logs (W3C format) ship cleanly to any log aggregator. Configure them to rotate daily and to include time-taken, sc-bytes, cs-bytes, and cs(User-Agent).
Patching
The .NET Framework patch cadence is monthly via Windows Update. Treat it as part of your routine maintenance:
- Test patches on a dev VM before production
- Schedule a maintenance window for production patching
- IIS Application Initialization is your friend — preloads the app on startup so users don’t hit a cold start
The Modernization On-Ramp
A post about a .NET Framework 4.8 app should at least mention the path forward. You have options, in increasing order of effort:
- Stay on 4.8. It’s supported through the lifetime of the Windows Server versions that include it. If your app isn’t growing, this is a reasonable choice.
- Port to .NET 8 / .NET 9. Microsoft’s
dotnet upgrade-assistanttool gets you most of the way for ASP.NET MVC apps. ASP.NET Web Forms does not have a clean upgrade path; expect a rewrite. - Containerize on Linux. Only available after step 2. The reward is dramatically lighter container images, cross-platform CI, and the ability to run on Kubernetes.
- Re-platform. Replace the app entirely. Sometimes the right answer for apps that have outgrown their original design.
The trap is doing all four at once. The pragmatic order is: get the 4.8 app deployable with the patterns in this post, then port to modern .NET, then containerize on Linux. Each step is independently valuable.
A Working Daily Loop
To put it all together, here’s what daily development looks like with everything set up:
|
|
Production deployments go through the same package the CI built — never a special build, never a hand-tweaked artifact.
What This Setup Actually Buys You
The reason any of this is worth doing:
- Local dev doesn’t touch production. Per-developer databases, isolated config, containerized web app — every breaking change you write stays on your machine until you push.
- The shared test environment isn’t a contention point. Per-PR environments mean two people testing two features can both have working deployments.
- Deploys are boring. Same artifact promoted through environments, same script applied to each, same observability across the pipeline.
- Onboarding new developers takes hours, not weeks. Clone, run docker compose, run the migrator, F5. Everything else is documented in the README.
- Modernization is incremental. When you’re ready to port to .NET 8, you have a clean baseline to work from instead of a tangle of “works on my machine” environments.
The .NET Framework 4.8 / IIS / SQL Server stack is mature and stable. The developer experience around it can be too — it just takes deliberate setup of the patterns above. None of this is exotic; all of it is well-trodden. The hard part is committing to do it instead of perpetually accepting the broken status quo.
Build, deploy, isolate, observe. The rest is engineering.
Comments