ASP.NET Core CI/CD: GitHub Actions + IIS Guide

If you're still publishing your ASP.NET Core app by right-clicking → Publish → Folder in Visual Studio, you're doing it wrong. Modern .NET deployment is reproducible, scripted, and ideally takes you from git push to live production in under 5 minutes with zero downtime.

Here's the GitHub Actions + IIS pipeline we ship for Adaptive's ASP.NET Core hosting customers, plus the patterns that prevent the worst CI/CD pain points.

< 5 min | Git Push to Production

0 sec | Downtime (Blue-Green)

Auto | Rollback on Health-Check Fail

10 Sites | On Professional Plan

!Continuous integration pipeline visualization

The Goal

A working .NET CI/CD pipeline should:

  • Build on every push — catch compile errors before merge
  • Run tests — unit, integration, smoke
  • Produce a deployable artifact — once, used everywhere
  • Deploy to staging on merge to main — automatic verification environment
  • Promote to production with a click — or automatic, with feature flags
  • Roll back automatically — if health checks fail post-deploy

We'll wire all of these together for IIS 10 on Windows Server 2022.

Project Layout

The pipeline assumes a typical ASP.NET Core repo:

/src

/MyApp.Web — ASP.NET Core project

/MyApp.Domain — business logic

/MyApp.Infrastructure — EF Core, repositories

/tests

/MyApp.Tests — unit + integration

/.github/workflows

ci-cd.yml — our pipeline

MyApp.sln

global.json — pinned .NET SDK

Pin Your .NET SDK

global.json at the repo root locks the SDK version so CI matches local dev:
{

"sdk": {

"version": "10.0.100",

"rollForward": "latestFeature"

}

}

> 💡 Why pin: This eliminates "works on my machine" disasters where a teammate's SDK 9.0.200 emits subtly different IL than the CI runner's 10.0.100.

GitHub Actions Workflow

The complete .github/workflows/ci-cd.yml:

name: ASP.NET Core CI/CD

on:

push:

branches: [main, develop]

pull_request:

branches: [main]

env:

DOTNET_VERSION: '10.0.x'

SOLUTION_FILE: 'MyApp.sln'

PROJECT_PATH: 'src/MyApp.Web/MyApp.Web.csproj'

jobs:

build-test:

runs-on: windows-latest

steps:

- uses: actions/checkout@v4

- name: Setup .NET

uses: actions/setup-dotnet@v4

with:

dotnet-version: ${{ env.DOTNET_VERSION }}

- name: Restore

run: dotnet restore ${{ env.SOLUTION_FILE }}

- name: Build

run: dotnet build ${{ env.SOLUTION_FILE }} --configuration Release --no-restore

- name: Test

run: dotnet test ${{ env.SOLUTION_FILE }} --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage"

- name: Publish

if: github.ref == 'refs/heads/main'

run: dotnet publish ${{ env.PROJECT_PATH }} --configuration Release --output ./publish --no-build

- name: Upload artifact

if: github.ref == 'refs/heads/main'

uses: actions/upload-artifact@v4

with:

name: app-package

path: ./publish

deploy-staging:

needs: build-test

if: github.ref == 'refs/heads/main'

runs-on: ubuntu-latest

environment: staging

steps:

- uses: actions/download-artifact@v4

with:

name: app-package

path: ./publish

- name: Deploy via WebDeploy

run: |

curl -X POST -u "${{ secrets.DEPLOY_USER }}:${{ secrets.DEPLOY_PASS }}" \

-H "Content-Type: application/zip" \

--data-binary "@./publish.zip" \

"${{ secrets.STAGING_DEPLOY_URL }}"

- name: Smoke test

run: |

for i in {1..30}; do

if curl -fsS https://staging.example.com/health; then exit 0; fi

sleep 10

done

exit 1

deploy-production:

needs: deploy-staging

if: github.ref == 'refs/heads/main'

runs-on: ubuntu-latest

environment: production

steps:

- uses: actions/download-artifact@v4

with:

name: app-package

path: ./publish

- name: Deploy

run: |

curl -X POST -u "${{ secrets.PROD_USER }}:${{ secrets.PROD_PASS }}" \

-H "Content-Type: application/zip" \

--data-binary "@./publish.zip" \

"${{ secrets.PROD_DEPLOY_URL }}"

- name: Health check + auto-rollback

run: |

for i in {1..30}; do

if curl -fsS https://www.example.com/health; then exit 0; fi

sleep 10

done

# rollback by re-deploying previous artifact

exit 1

The environment: production block requires manual approval in GitHub before the production deploy runs.

Deploying to IIS Without Downtime

The brute-force approach (stop site → copy files → start site) creates 5–30 seconds of downtime. Two better patterns:

| Pattern | How It Works | Downtime | When to Use |

|---|---|---|---|

| App Offline file | Drop app_offline.htm → IIS gracefully shuts down → deploy → remove file | ~10 sec maintenance page | Small sites, infrequent deploys |

| Blue-Green via two IIS sites | Deploy to standby site, run smoke tests, flip DNS or upstream | 0 sec | Production, frequent deploys |

Our Professional plan includes 10 websites, so you can easily run staging and production side-by-side for blue-green deployments. Business plan customers get 5 websites; Developer plan supports 2.

Publishing Profile

src/MyApp.Web/Properties/PublishProfiles/Production.pubxml:
<Project>

<PropertyGroup>

<PublishProtocol>WebDeploy</PublishProtocol>

<Configuration>Release</Configuration>

<TargetFramework>net10.0</TargetFramework>

<PublishUrl>https://hosting.example.com:8172/msdeploy.axd</PublishUrl>

<MSDeployServiceURL>hosting.example.com:8172</MSDeployServiceURL>

<DeployIisAppPath>example.com</DeployIisAppPath>

<SkipExtraFilesOnServer>true</SkipExtraFilesOnServer>

<SelfContained>false</SelfContained>

<PublishTrimmed>false</PublishTrimmed>

</PropertyGroup>

</Project>

> ⚠️ SkipExtraFilesOnServer=true prevents accidentally deleting user uploads that aren't in source control. Without it, every deploy wipes your wwwroot/uploads/ folder.

Health Check Endpoint

Your app needs a /health endpoint that exercises critical dependencies:

builder.Services.AddHealthChecks()

.AddSqlServer(builder.Configuration.GetConnectionString("Default")!)

.AddCheck<DiskSpaceHealthCheck>("disk-space")

.AddRedis(builder.Configuration.GetConnectionString("Redis")!, failureStatus: HealthStatus.Degraded);

app.MapHealthChecks("/health", new HealthCheckOptions

{

ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,

});

/health returns 200 OK only when all checks pass. CI uses this to verify successful deployment.

!Server monitoring dashboard

Automatic Rollback

If health checks fail after deploy, you want to roll back automatically. Three approaches:

| Approach | Complexity | Rollback Time |

|---|---|---|

| Re-deploy previous artifact | Low | 30–60 sec |

| Swap IIS site bindings (blue-green) | Medium | ⚠️ Rule 1: Migrations are always backward-compatible. Old code must run against new schema. This means add columns NULL-able first, deploy code that writes both, then make NOT NULL in a later migration.

> ⚠️ Rule 2: Migrations run separately from app deploy. Use a CLI step in CI, not MigrateAsync() on app startup. Otherwise you have multiple instances racing the migration.

> ⚠️ Rule 3: Test rollback paths. Every migration should have a tested down-migration. EF Core supports this but most teams skip it.

- name: Apply migrations

run: dotnet ef database update --project src/MyApp.Web --connection "${{ secrets.PROD_CONNECTION_STRING }}"

Secrets Management

Never commit connection strings or API keys. Use:

| Layer | Tool |

|---|---|

| Development | User Secrets (dotnet user-secrets) |

| CI | GitHub Secrets / Azure DevOps Library |

| Runtime | appsettings.Production.json outside web root, OR environment variables, OR Azure Key Vault, OR AWS Secrets Manager |

For larger teams: HashiCorp Vault or AWS Secrets Manager with appsettings.json provider:

builder.Configuration.AddAWSSecretsManager(builder.Configuration["AWS:SecretsArn"]);

Monitoring the Pipeline Itself

Your CI/CD pipeline can fail silently if you're not watching:

  • GitHub Actions sends Slack/email on workflow failure
  • Sentry or Application Insights captures runtime exceptions post-deploy
  • Real User Monitoring detects user-facing regressions even when health checks pass

Why This Works on Adaptive

Our ASP.NET Core hosting is configured for CI/CD out of the box:

| Feature | What You Get |

|---|---|

| WebDeploy via Plesk | Secure deployment endpoint, per-site credentials |

| Dedicated IIS app pool | Your deploys never affect other tenants (1/2/4 GB RAM by plan) |

| Multiple websites per plan | 2 (Developer), 5 (Business), 10 (Professional) — useful for staging/prod separation |

| SQL Server 2022 databases | 2 / 5 / 10 by plan, with Query Store enabled |

| MariaDB databases | 2 / 5 / 10 by plan, alongside SQL Server |

| Plesk Control Panel | Git deploy, scheduled tasks, log viewer |

| Higher Priority Resource Scheduling | Smooth deploys under load (Business and Professional) |

Frequently Asked Questions

Can I use Azure DevOps instead of GitHub Actions?

Yes — Azure Pipelines integrates with WebDeploy natively. The pattern is identical: build artifact → deploy → smoke test → rollback path.

What about Docker-based deployments?

Windows containers on IIS work, but they add complexity for marginal benefit on small-to-medium .NET apps. Most teams are better off with WebDeploy until they need Kubernetes-level orchestration.

How do I deploy database migrations safely?

Make them backward-compatible (NULL-able adds, no destructive drops in the same deploy), run them as a separate CI step before the app deploy, and always have a tested rollback path.

Can the pipeline catch performance regressions?

Yes — add a load-test step (k6, NBomber) running against staging post-deploy. Set thresholds for p95 latency and error rate; fail the pipeline if exceeded.

Does Adaptive support self-hosted GitHub Actions runners?

Yes — you can run runners on a Plesk-managed Windows VM if you need access to private resources. We help configure this for Professional-plan customers.

title: Ship Faster With Hosting Built for CI/CD

description: WebDeploy via Plesk. Dedicated IIS app pools so your deploys never break neighbors. SQL Server 2022 with Query Store. Plans from $9.49/mo.

cta-primary: ASP.NET Core Hosting | /asp-net-core-hosting

cta-secondary: CI/CD Customer Stories | /case-studies

Talk to our team about setting up CI/CD for your application — or compare all hosting plans side by side.
Back to Blog