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.
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.