🚀 CI/CD Flow to Deploy .NET Framework 4.8 Using GitLab CI on Windows Server 2025 (VM-Based Setup)
Continuous Integration and Continuous Deployment (CI/CD) is no longer just for modern .NET Core or containerized applications — even legacy .NET Framework 4.8 apps can enjoy the same automation.
In this post, I’ll walk through how to build a GitLab CI/CD pipeline to deploy a .NET Framework 4.8 web application onto a Windows Server 2025 virtual machine.
🏗️ Architecture Overview
Here’s the overall setup:
Developer → GitLab Repository → GitLab Runner (Windows VM)
↓
Build Artifacts (MSBuild)
↓
Deploy to IIS (Windows Server 2025)We’ll use:
- GitLab CI/CD for automation
- Windows Server 2025 as both the Runner host and IIS web server
- MSBuild
- PowerShell scripts to handle deployment
- Sonar-Scanner-Net and OpenCover to handle sonarqube scanner
- NuGet to handle package manager
⚙️ Prerequisites
- Windows Server 2025 VM (can be on-prem or Azure/VMware)
- GitLab Runner installed with shell executor
- .NET Framework 4.8 Developer Pack installed
- NuGet installed
- Sonar-Scanner-Net installed
- MSBuild (from Visual Studio Build Tools 2022)
- OpenCover installed
🧱 Step 1: Create .gitlab-ci.yml
Here’s a working pipeline definition:
stages:
- sast
- build
- deploy
variables:
SONAR_PROJECT_NAME: "<application_name>"
APP_DIR_NAME: "<application_dir_name>"
APP_DIR_TEST_NAME: "<application_dir_test_name>"
SONARQUBE_TOKEN: "<sonar_token>"
SONARQUBE_URL: "<sonar_url>"
SHARE_FOLDER: "<share_folder>"
va_analysis:
stage: sast
tags:
- docker
image: grype-syft:latest
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "testing" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
- when: never
before_script:
# Verify tools are available
- which syft
- which grype
- syft --version
- grype --version
script:
# Generate SBOM
- echo "Generating SBOM..."
- syft dir:. -o cyclonedx-json > sbom.json
- syft dir:. -o spdx-json > sbom-spdx.json
- echo "SBOM generated successfully"
# Scan for vulnerabilities
- echo "Scanning for vulnerabilities..."
- grype sbom:sbom.json --output json > grype-report.json
- grype sbom:sbom.json --output sarif > grype-report.sarif
- grype sbom:sbom.json --output table > grype-report.txt
# Optional: Show summary (but don't fail on high vulnerabilities yet)
- echo "Vulnerability scan completed"
- cat grype-report.txt
artifacts:
paths:
- sbom.json
- sbom-spdx.json
- grype-report.json
- grype-report.sarif
- grype-report.txt
expire_in: 1 days
reports:
# GitLab can parse these for security dashboard
cyclonedx: sbom.json
sast: grype-report.sarif
allow_failure: false # Set to true if you want the pipeline to continue even with high vulnerabilities
sonarqube_analysis:
stage: sast
tags:
- windows
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "testing" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
variables:
SONARSCANNER_DIR: "C:\\Sonar-Scanner-Net"
MSBUILD_DIR: "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\MSBuild\\Current\\Bin"
VTEST_DIR: "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\IDE\\Extensions\\TestPlatform"
OPENCOVER_DIR: "C:\\Tools\\opencover"
NUGET_DIR: "C:\\Program Files\\NuGet"
- when: never
before_script:
- |
# Create directories for reports
if (!(Test-Path "build-reports")) {
New-Item -ItemType Directory -Path "build-reports"
}
script:
- |
& "$SONARSCANNER_DIR\SonarScanner.MSBuild.exe" `
begin /k:"$SONAR_PROJECT_NAME" `
/n:"$SONAR_PROJECT_NAME" `
/v:"$CI_COMMIT_SHORT_SHA" `
/d:sonar.host.url="$SONAR_HOST_URL" `
/d:sonar.login="$SONAR_TOKEN" `
/d:sonar.gitlab.project_id="$CI_PROJECT_ID" `
/d:sonar.gitlab.commit_sha="$CI_COMMIT_SHA" `
/d:sonar.gitlab.ref_name="$CI_COMMIT_REF_NAME" `
/d:sonar.cs.opencover.reportsPaths="coverage.xml" `
/d:sonar.exclusions="**/Content/**,**/Scripts/**,**/packages/**,**/Service References/**,**/Migrations/**"
if ($LASTEXITCODE -ne 0) {
echo "SonarQube analysis failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
- |
& "$NUGET_DIR\nuget.exe" `
restore `
".\"$APP_DIR_NAME".sln"
- |
echo "Building solution..."
& "$MSBUILD_DIR\MSBuild.exe" `
"$APP_DIR_NAME".sln `
/p:Configuration=Release `
/p:Platform="Any CPU" `
/fl /flp:logfile=build-reports\msbuild.log;verbosity=normal
if ($LASTEXITCODE -ne 0) {
echo "Build failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
echo "Build completed successfully"
- |
& "$OPENCOVER_DIR\OpenCover.Console.exe" `
-target:"$VTEST_DIR\vstest.console.exe" `
-targetargs:"`""$APP_DIR_TEST_NAME"\bin\Debug\"$APP_DIR_TEST_NAME".dll`" /Logger:trx" `
-output:"coverage.xml" `
-register:user `
-filter:"+[GBOnline*]* -["$APP_DIR_TEST_NAME"*]*"
- |
& "$SONARSCANNER_DIR\SonarScanner.MSBuild.exe" `
end /d:sonar.login="$SONAR_TOKEN"
if ($LASTEXITCODE -ne 0) {
echo "SonarQube analysis failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
echo "SonarQube analysis completed successfully"
artifacts:
paths:
- build-reports/
expire_in: 1 days
reports:
junit: sonar-reports/report-task.txt
allow_failure: false
build_dotnet_framework:
stage: build
tags:
- windows-dev # Must match your GitLab Windows runner tag
rules:
- if: '($CI_COMMIT_REF_NAME == "development" || $CI_COMMIT_REF_NAME == "testing") && $CI_PIPELINE_SOURCE == "push"'
variables:
MSBUILD_DIR: "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\MSBuild\\Current\\Bin"
NUGET_DIR: "C:\\Program Files\\NuGet"
- when: never
script:
- echo "Building .NET Framework 4.6 project..."
- |
& "$NUGET_DIR\nuget.exe" `
restore `
".\"$APP_DIR_NAME".sln"
- echo "Building .NET Framework 4.6 project..."
- |
& "$MSBUILD_DIR\MSBuild.exe" `
""$APP_DIR_NAME"\"$APP_DIR_NAME".csproj" `
/p:Configuration=Release `
/p:Platform="Any CPU" `
/p:DeployOnBuild=true `
/p:OutputPath="bin"
- echo "Build completed. Checking output..."
- echo "Zip Application..."
- |
& Compress-Archive -Path ""$APP_DIR_NAME"/bin/_PublishedWebsites/"$APP_DIR_NAME"/*" `
-DestinationPath ""$APP_DIR_NAME".zip"
artifacts:
paths:
- "$APP_DIR_NAME".zip
expire_in: 1 days
deploy_dotnet_framework:
stage: deploy
tags:
- windows-dev
rules:
- if: '($CI_COMMIT_REF_NAME == "development" || $CI_COMMIT_REF_NAME == "testing") && $CI_PIPELINE_SOURCE == "push"'
- when: never
script:
- |
echo "Connecting to network share..."
net use X: \\$IP_HOST\$SHARE_FOLDER /user:$USER $PASS
echo "Creating deployment directory..."
New-Item -Path X:\temp -ItemType Directory -Force
New-Item -Path X:\"$APP_DIR_NAME" -ItemType Directory -Force
echo "Copying application zip..."
Copy-Item -Path ""$APP_DIR_NAME".zip" -Destination "X:\temp\"
echo "Unzipping assets..."
Expand-Archive -Path "X:\temp\"$APP_DIR_NAME".zip" -DestinationPath "X:\"$APP_DIR_NAME"\" -Force
echo "Cleaning up..."
Remove-Item -Path "X:\temp" -Recurse -Force
echo "Disconnecting from network share..."
net use X: /delete
dependencies:
- build_dotnet_framework🧾 Step 2: Installation Programs
Install GitLab Runner on windows server as runner can read from official website GitLab Install GitLab Runner on Windows | GitLab Docs and register on your own GitLab.
Get darosy imam’s stories in your inbox
Join Medium for free to get updates from this writer.
if using powershell as shell, edit config.toml
[[runners]]
name = "<runner-name>"
url = "https://gitlab.com"
token = "<token>"
executor = "shell"
shell = "powershell"
[runners.cache]
MaxUploadedArchiveSize = 0
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]Install NuGet from official NuGet Gallery | Downloads, Sonar Scanner for .Net SonarScanner for .NET | Sonar Documentation, and OpenCover Releases · OpenCover/opencover.
🪄 Step 3: Secure Your Secrets
You can store sensitive data like server credentials or API tokens in GitLab CI/CD Variables (Settings → CI/CD → Variables).
Example:
🏁 Conclusion
Even though .NET Framework 4.8 isn’t container-native, it can still be deployed with modern CI/CD practices.
With GitLab CI, PowerShell, and Windows Server 2025, you can automate everything — from build to IIS deployment — and bring your legacy applications into a continuous delivery flow.
💡 Tip: The same setup can be extended to deploy multiple environments (Dev/Test/Prod) using GitLab environments and branch-based rules.
✍️ Author’s Note
If you’re maintaining legacy .NET apps and want to bring modern DevOps practices to your stack, this approach is a solid starting point.
You can enhance it further with versioned artifacts, approval stages, and monitoring integrations.







