LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

.NET Framework 4.8 + IIS + SQL Server: A Modern Development and Deployment Workflow for Legacy Apps

dotnetdotnet-frameworkiissql-serverdockerwindowslegacymsbuildweb-deployoctopus-deploygithub-actions

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 dotnet CLI utilities like dotnet-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:

  1. SQL Server in a Docker Linux container (recommended for most cases)
  2. SQL Server Developer Edition installed on the host
  3. 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.exe on 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.props keeps NuGet versions in one place. This was added in NuGet 6.2 and works fine with Framework projects.
  • Directory.Build.props lets you set things like TreatWarningsAsErrors, 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- web.Release.config -->
<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
  <connectionStrings>
    <add name="Main"
         connectionString="Server=prod-sql;Database=MyApp;Integrated Security=true"
         xdt:Transform="SetAttributes" xdt:Locator="Match(name)"/>
  </connectionStrings>
  <system.web>
    <compilation xdt:Transform="RemoveAttributes(debug)"/>
  </system.web>
</configuration>

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:

1
2
<connectionStrings configSource="connectionStrings.config" />
<appSettings configSource="appSettings.config" />

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:

1
2
3
4
5
<Target Name="CopyDevConfigs" BeforeTargets="Build" Condition="'$(Configuration)' == 'Debug'">
  <Copy SourceFiles="connectionStrings.local.config"
        DestinationFiles="connectionStrings.config"
        Condition="Exists('connectionStrings.local.config')" />
</Target>

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:

1
2
3
4
5
6
7
8
9
public static void Configure()
{
    foreach (string key in ConfigurationManager.AppSettings.AllKeys)
    {
        var envValue = Environment.GetEnvironmentVariable($"APP_{key}");
        if (envValue != null)
            ConfigurationManager.AppSettings[key] = envValue;
    }
}

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.

Use all three, in this order of preference for any given setting:

  1. Environment variables for anything Docker/deploy should override
  2. External config files (configSource) for connection strings and per-developer settings
  3. 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_jeff for me
  • MyApp_alice for Alice
  • MyApp_bob for Bob

Connection strings are templated. The local-config pattern above lets each developer point at their own database without conflicts:

1
2
3
4
5
6
<!-- connectionStrings.local.config (not in source control) -->
<connectionStrings>
  <add name="Main"
       connectionString="Server=localhost,1433;Database=MyApp_jeff;User Id=sa;Password=YourDev_Pwd1!;TrustServerCertificate=true"
       providerName="System.Data.SqlClient" />
</connectionStrings>

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// /db/MyApp.DbMigrator/Program.cs
static int Main(string[] args)
{
    var connectionString = args.Length > 0
        ? args[0]
        : Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");

    var upgrader = DeployChanges.To
        .SqlDatabase(connectionString)
        .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
        .WithTransactionPerScript()
        .LogToConsole()
        .Build();

    var result = upgrader.PerformUpgrade();
    return result.Successful ? 0 : -1;
}

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:

  1. Take a production backup (.bak file)
  2. 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)
  3. Run a data sanitization pass that masks PII, scrambles emails, resets passwords
  4. Take a backup of the sanitized DB
  5. 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# /docker/Dockerfile.web
# escape=`

FROM mcr.microsoft.com/dotnet/framework/sdk:4.8-windowsservercore-ltsc2022 AS build
WORKDIR /src

COPY ["src/MyCompany.WebApp/MyCompany.WebApp.csproj", "MyCompany.WebApp/"]
COPY ["src/MyCompany.WebApp.Core/MyCompany.WebApp.Core.csproj", "MyCompany.WebApp.Core/"]
COPY ["src/MyCompany.WebApp.Data/MyCompany.WebApp.Data.csproj", "MyCompany.WebApp.Data/"]
COPY ["Directory.Build.props", "."]
COPY ["Directory.Packages.props", "."]

RUN nuget restore MyCompany.WebApp/MyCompany.WebApp.csproj

COPY src/ .

RUN msbuild MyCompany.WebApp/MyCompany.WebApp.csproj `
    /p:Configuration=Release `
    /p:DeployOnBuild=true `
    /p:PublishUrl=C:\publish `
    /p:WebPublishMethod=FileSystem `
    /p:DeployDefaultTarget=WebPublish

FROM mcr.microsoft.com/dotnet/framework/aspnet:4.8-windowsservercore-ltsc2022 AS runtime
WORKDIR /inetpub/wwwroot
COPY --from=build C:/publish ./

# Configure IIS for the app
RUN powershell -Command `
    Import-Module WebAdministration; `
    Set-ItemProperty 'IIS:\Sites\Default Web Site' -Name applicationPool -Value DefaultAppPool

ENV ASPNET_ENVIRONMENT=Development
EXPOSE 80

The base image is ~8GB. That’s the cost of doing business with Windows containers; pulled once, cached forever.

Docker Compose

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# /docker/docker-compose.yml
version: '3.8'

services:
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile.web
    ports:
      - "8080:80"
    environment:
      - APP_ConnectionString=Server=sql;Database=MyApp;User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=true
      - APP_Environment=Local
    depends_on:
      - sql
    platform: windows
    networks:
      - app

  sql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_SA_PASSWORD=${SA_PASSWORD}
      - MSSQL_PID=Developer
    ports:
      - "1433:1433"
    volumes:
      - sql-data:/var/opt/mssql
    platform: linux
    networks:
      - app

  migrator:
    build:
      context: ..
      dockerfile: docker/Dockerfile.migrator
    environment:
      - DB_CONNECTION_STRING=Server=sql;Database=MyApp;User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=true
    depends_on:
      - sql
    platform: windows
    networks:
      - app

networks:
  app:
    driver: nat

volumes:
  sql-data:

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# /docker/docker-compose.override.yml (gitignored)
services:
  web:
    ports:
      - "9090:80"   # Jeff likes port 9090
    environment:
      - APP_FeatureFlags__NewCheckout=true
  sql:
    ports:
      - "11433:1433"

Running it

1
2
3
4
cd docker
docker compose up --build -d
docker compose run --rm migrator
docker compose logs -f web

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:

  1. The web app as a Web Deploy package — a .zip containing the published site plus a parameters file
  2. The database migrator — either an executable that applies scripts, or a DACPAC

MSBuild from the command line

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# /build/build.ps1
param(
    [string]$Configuration = "Release",
    [string]$OutputPath = "$PSScriptRoot\..\artifacts"
)

# Restore
& nuget.exe restore "$PSScriptRoot\..\MyApp.sln"

# Build + publish
& msbuild.exe "$PSScriptRoot\..\src\MyCompany.WebApp\MyCompany.WebApp.csproj" `
    /p:Configuration=$Configuration `
    /p:DeployOnBuild=true `
    /p:WebPublishMethod=Package `
    /p:PackageAsSingleFile=true `
    /p:SkipInvalidConfigurations=true `
    /p:PackageLocation="$OutputPath\MyApp.zip" `
    /p:DeployIisAppPath="Default Web Site/MyApp"

# Build the migrator
& msbuild.exe "$PSScriptRoot\..\db\MyApp.DbMigrator\MyApp.DbMigrator.csproj" `
    /p:Configuration=$Configuration `
    /p:OutputPath="$OutputPath\Migrator"

Write-Host "Artifacts at $OutputPath"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- parameters.xml -->
<parameters>
  <parameter name="IIS Web Application Name"
             defaultValue="Default Web Site/MyApp"
             tags="IisApp"/>
  <parameter name="MainConnectionString"
             defaultValue="Server=.;Database=MyApp;Integrated Security=true"
             description="Primary application connection string">
    <parameterEntry kind="XmlFile"
                    scope="\\web.config$"
                    match="//connectionStrings/add[@name='Main']/@connectionString"/>
  </parameter>
</parameters>

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# /.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: windows-2022
    steps:
      - uses: actions/checkout@v4

      - name: Setup MSBuild
        uses: microsoft/setup-msbuild@v2

      - name: Setup NuGet
        uses: NuGet/setup-nuget@v2

      - name: Restore
        run: nuget restore MyApp.sln

      - name: Build
        run: msbuild MyApp.sln /p:Configuration=Release /p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:PackageLocation=$Env:GITHUB_WORKSPACE\artifacts\MyApp.zip

      - name: Test
        run: |
          $vstest = Get-ChildItem -Path "C:\Program Files\Microsoft Visual Studio" -Recurse -Filter "vstest.console.exe" | Select-Object -First 1
          & $vstest.FullName src\MyCompany.WebApp.Tests\bin\Release\MyCompany.WebApp.Tests.dll

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: webapp-package
          path: artifacts/MyApp.zip

      - name: Upload migrator
        uses: actions/upload-artifact@v4
        with:
          name: db-migrator
          path: db/MyApp.DbMigrator/bin/Release

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:

1
2
3
4
5
6
7
& "C:\Program Files\IIS\Microsoft Web Deploy V3\msdeploy.exe" `
    -verb:sync `
    -source:package="artifacts\MyApp.zip" `
    -dest:auto,computerName="https://staging.example.com:8172/msdeploy.axd?site=Default Web Site",userName="deploy",password="$(DeployPassword)",authtype="Basic",includeAcls="False" `
    -setParam:name="IIS Web Application Name",value="Default Web Site/MyApp" `
    -setParam:name="MainConnectionString",value="Server=staging-sql;Database=MyApp;User Id=appuser;Password=$(SqlPassword)" `
    -allowUntrusted

Set up:

  1. Install IIS Web Deploy 3.6+ on the target server with the management service enabled
  2. Create a deploy user with permissions to the IIS site (typically a service account)
  3. Open port 8172 from your CI runner to the target
  4. 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:

  1. CI builds the package and pushes it to Octopus’s built-in artifact repository
  2. Octopus creates a release tied to the package version
  3. Releases progress through environments (Dev → Test → Staging → Prod) with optional approval gates
  4. Each environment has its own variable set (connection strings, feature flags, app pool names)
  5. 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:

1
2
3
4
5
6
# Run the DbUp migrator against the target
& artifacts\Migrator\MyApp.DbMigrator.exe "Server=staging-sql;Database=MyApp;User Id=deploy;Password=$(SqlPassword)"
if ($LASTEXITCODE -ne 0) { throw "Migration failed" }

# Then deploy the web app
& msdeploy.exe -verb:sync -source:package=... -dest:auto,computerName=...

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:

  1. Expand: Add the new column, keep the old. Deploy.
  2. Backfill: Migrate data from old to new. Deploy.
  3. Switch: Make the app read/write the new column. Deploy.
  4. 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.com for 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:

1
2
3
4
5
6
7
8
9
Import-Module WebAdministration
$prNumber = $env:PR_NUMBER
$siteName = "pr-$prNumber"
$physicalPath = "C:\inetpub\$siteName"

New-Item -Path $physicalPath -ItemType Directory -Force
New-WebSite -Name $siteName -PhysicalPath $physicalPath -Port 80 -HostHeader "$siteName.dev.example.com"
New-WebAppPool -Name $siteName
Set-ItemProperty "IIS:\Sites\$siteName" -Name applicationPool -Value $siteName

Plus a SQL database per PR:

1
2
3
CREATE DATABASE [MyApp_pr_123];
GO
-- Run migrations against MyApp_pr_123, then seed

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:

1
2
3
4
5
6
7
// Global.asax.cs
Log.Logger = new LoggerConfiguration()
    .ReadFrom.AppSettings()
    .Enrich.FromLogContext()
    .Enrich.WithProperty("App", "MyApp")
    .Enrich.WithProperty("Environment", ConfigurationManager.AppSettings["Environment"])
    .CreateLogger();

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:

  1. 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.
  2. Port to .NET 8 / .NET 9. Microsoft’s dotnet upgrade-assistant tool 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.
  3. 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.
  4. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Morning
git pull
docker compose up -d sql                          # Start SQL container
dotnet build src/MyCompany.WebApp.sln             # Build solution
docker compose run --rm migrator                  # Apply any new migrations

# Inner loop
# - F5 in VS to launch IIS Express + debugger
# - Make changes, rebuild, refresh browser
# - Run xunit/nunit tests locally

# Before pushing
docker compose up --build -d web                  # Build and run in IIS container
# - Hit http://localhost:8080
# - Verify it works under real IIS, not just IIS Express

git push origin feature/checkout-redesign         # CI takes over
# - CI builds, tests, packages
# - Per-PR environment auto-deploys to pr-123.dev.example.com
# - Reviewer can click the URL and try the change live

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