Microsoft Workloads on AWS

Zero-downtime multi-tenant IIS deployments on AWS Elastic Beanstalk

Introduction

If you host multiple ASP.NET web applications on a single AWS Elastic Beanstalk (Elastic Beanstalk) Windows Server environment, you face a common operational challenge: every deployment restarts Microsoft Internet Information Services (IIS), which takes down all sites on the instance, even the ones that did not change. For multi-tenant workloads where each IIS site serves a different customer, a routine update to one tenant causes downtime for all tenants.

This post walks you through building a multi-tenant Elastic Beanstalk environment where you can update individual tenant sites independently. You will use the skipIISReset flag in the deployment manifest to prevent IIS from restarting, dedicated IIS application pools to isolate each tenant, and content-hash change detection in the PowerShell deployment scripts to skip unchanged tenants entirely. The result is true zero-downtime deployments for unaffected tenants.

The complete source code is available in the sample-beanstalk-multiple-IIS-websites repository on GitHub.

Solution overview

The solution consists of three ASP.NET Razor Pages tenant applications, a static health check page for the Elastic Beanstalk load balancer, per-application PowerShell deployment scripts, and an AWS CloudFormation (CloudFormation) template that provisions the environment.

The key components are:

  • A deployment manifest with skipIISReset: true to skip during deployment operations.
  • Dedicated IIS application pools per tenant that isolate each site’s worker process.
  • Content-hash change detection in the uninstall, install, and restart scripts that skips the deployment cycle for tenants whose files have not changed.
  • An Amazon Route 53 (Route 53) private hosted zone for subdomain resolution within the Amazon Virtual Private Cloud (VPC).

Architecture

An Application Load Balancer in public subnets distributes traffic across Elastic Beanstalk EC2 instances in private subnets. The instances span two Availability Zones and are managed by an Auto Scaling group. NAT Gateways provide outbound internet access; Route 53 resolves the private multiapp.local zone, and CloudFormation, S3, IAM, and Session Manager handle provisioning, artifacts, permissions, and secure instance access.

Figure 1: High-level architecture

Figure 1 presents the high-level architecture of the solution. The workload is deployed within a single AWS Region and resides in a VPC that spans two Availability Zones to ensure high availability. An Application Load Balancer in the public subnets distributes incoming traffic to Amazon EC2 (EC2) instances in the private subnets, which are managed by an Auto Scaling group and provisioned through Elastic Beanstalk and CloudFormation. Network Address Translation (NAT) gateways provide controlled outbound connectivity, while a Route 53 private hosted zone (multiapp.local) enables internal DNS resolution. Application artifacts are stored in Amazon S3 (S3), access is governed by an AWS Identity and Access Management (IAM) role, and a private test instance is accessed securely through AWS Systems Manager Session Manager (Session Manager).

Prerequisites

To follow along with this walkthrough, you will need:

  • An AWS account with permissions to create Elastic Beanstalk, CloudFormation, Route 53, IAM, and EC2 resources.
  • .NET 8 SDK or later.
  • PowerShell 7+.
  • AWS Command Line Interface (AWS CLI) configured with appropriate credentials.
  • A VPC with public and private subnets across at least two Availability Zones, with a NAT gateway for outbound internet from private subnets.

Walkthrough

How Elastic Beanstalk custom deployments work

With Elastic Beanstalk, you can host multiple applications on a single Windows Server environment through a deployment manifest file named aws-windows-deployment-manifest.json. The manifest defines custom deployment entries, each referencing three PowerShell scripts: install, uninstall, and restart. During a deployment, Elastic Beanstalk runs the uninstall script for each application using the scripts from the previous deployment. Then runs the install script for each application using the scripts from the new deployment and finally runs the restart script for each application. By default, the Elastic Beanstalk also performs a full iisreset between these steps. The skipIISReset flag prevents this.A key detail: Elastic Beanstalk runs all three scripts for every application in the manifest on every deployment, regardless of which application changed. This is why content-hash change detection in the scripts is essential.

The deployment manifest

The manifest sets skipIISReset: true and declares each application with its three scripts:

{ 
  "manifestVersion": 1, 
  "skipIISReset": true, 
  "deployments": { 
    "custom": [ 
      { 
        "name": "Tenant1Site", 
        "scripts": { 
          "install": { "file": "scripts/Tenant1Site/install.ps1" }, 
          "uninstall": { "file": "scripts/Tenant1Site/uninstall.ps1" }, 
          "restart": { "file": "scripts/Tenant1Site/restart.ps1" } 
        } 
      } 
    ] 
  } 
} 

Tenant2Site and Tenant3Site follow the same pattern.

Content-hash change detection

Each script computes a content-based fingerprint of the incoming bundle files by hashing each file’s bytes with SHA256, concatenating the results, and producing a single SHA256 fingerprint. This fingerprint is stored in a .deploy-hash file on the instance. On the next deployment, the scripts compare the new fingerprint against the stored one. If they match, the script exits immediately. We use content hashing rather than timestamps because dotnet publish regenerates files with new timestamps on every build, even when the source has not changed. The three scripts coordinate in the following steps:

Step 1: The uninstall script compares hashes and skips teardown if unchanged.

Step 2: The install script compares hashes and skips file copy if unchanged; if changed, it stops the app pool, replaces files, creates the IIS site, writes the new hash, and drops a .needs-restart marker.

Step 3: The restart script checks for the marker and recycles only that tenant’s app pool if present.

When you deploy a bundle where only Tenant1Site changed, the flow looks like the following table.

Step Tenant1Site Tenant2Site Tenant3Site
Uninstall Hash mismatch, tears down Hash matches, exits Hash matches, exits
Install Deploys new files, writes hash Hash matches, exits Hash matches, exits
Restart Marker found, recycles pool No marker, exits No marker, exits

Dedicated application pools

ASP.NET Core default In-process hosting model is used, so each tenant site needs its own pool. The install script creates one using appcmd.exe:

$appcmd = "$env:SystemRoot\system32\inetsrv\appcmd.exe" 
& $appcmd add apppool /name:$TenantName /managedRuntimeVersion:"" 
& $appcmd add site /name:$TenantName /physicalPath:$TargetPath /bindings:"http/*:80:${HostHeader}" 
& $appcmd set app "$TenantName/" /applicationPool:$TenantName 

We use appcmd.exe instead of the WebAdministration PowerShell module because Elastic Beanstalk runs deployment scripts with 32-bit PowerShell, which cannot load the 64-bit-only module.

Deploying the infrastructure

The CloudFormation template provisions the Elastic Beanstalk application and environment, an S3 bucket for deployment bundles, and IAM roles. It also creates a Route 53 private hosted zone with CNAME records for each tenant subdomain, and a test EC2 instance for verification using Session Manager. Elastic Beanstalk instances run in private subnets, and the load balancer sits in public subnets.

aws cloudformation deploy \ 
  --template-file infrastructure/template.yaml \ 
  --stack-name multisite-demo-stack \ 
  --parameter-overrides \ 
      EnvironmentName=multisite-demo \ 
      VpcId=<YOUR_VPC_ID> \ 
      PublicSubnets=<PUBLIC_SUBNET_1>,<PUBLIC_SUBNET_2> \ 
      PrivateSubnets=<PRIVATE_SUBNET_1>,<PRIVATE_SUBNET_2> \ 
      TestInstanceSubnetId=<PRIVATE_SUBNET_1> \ 
  --capabilities CAPABILITY_IAM

Before deploying the bundle

This step applies if you are deploying a new application version to an already existing environment. To prove that content-hashing and iisResetSkip is working, get the PIDs of the w3wp processes on the instances.

EB_INSTANCE_ID=$(aws elasticbeanstalk describe-environment-resources \ 
  --environment-name multisite-demo \ 
  --query "EnvironmentResources.Instances[0].Id" --output text) 
 
aws ssm send-command \ 
  --instance-ids $EB_INSTANCE_ID \ 
  --document-name "AWS-RunPowerShellScript" \ 
  --parameters 'commands=["& \"$env:SystemRoot\\system32\\inetsrv\\appcmd.exe\" list wp"]' \ 
  --output json --query "Command.CommandId" 

After deployment, we will run the Session Manager command again and compare the PIDs.

Building and deploying the bundle

The PackageBundle.ps1 script runs dotnet publish for each tenant app, assembles the manifest, scripts, and published output, and compresses everything into bundle.zip. Upload it to S3 and deploy:

pwsh PackageBundle.ps1 
aws s3 cp bundle.zip s3://<BUCKET_NAME>/bundle-v1.zip 
aws elasticbeanstalk create-application-version \ 
  --application-name multisite-demo-app --version-label v1 \ 
  --source-bundle S3Bucket=<BUCKET_NAME>,S3Key=bundle-v1.zip 
aws elasticbeanstalk update-environment \ 
  --environment-name multisite-demo --version-label v1 

Verifying zero-downtime behavior

To verify that updating one tenant does not disrupt the others, follow these steps.

Step 1: Make a change to one tenant. On your workstation, edit src/Tenant1Site/Pages/Index.cshtml to change the heading text, for example adding a (v2) marker.

Step 2: Rebuild and deploy. Build a new bundle and deploy it:

pwsh PackageBundle.ps1 
aws s3 cp bundle.zip s3://<BUCKET_NAME>/bundle-v2.zip 
aws elasticbeanstalk create-application-version \ 
  --application-name multisite-demo-app --version-label v2 \ 
  --source-bundle S3Bucket=<BUCKET_NAME>,S3Key=bundle-v2.zip 
aws elasticbeanstalk update-environment \ 
  --environment-name multisite-demo --version-label v2 

Step 3: Verify w3wp process PIDs. From your workstation, run the following command on EB instance(s).

aws ssm send-command \ 
  --instance-ids $EB_INSTANCE_ID \ 
  --document-name "AWS-RunPowerShellScript" \ 
  --parameters 'commands=["& \"$env:SystemRoot\\system32\\inetsrv\\appcmd.exe\" list wp"]' \ 
  --output json --query "Command.CommandId" 

Example output:

WP "4840" (applicationPool:Tenant1Site)    <-- new PID, Tenant1Site was recycled 
WP "2812" (applicationPool:Tenant2Site)    <-- same PID, never restarted 
WP "3380" (applicationPool:Tenant3Site)    <-- same PID, never restarted 

Tenant2Site and Tenant3Site kept the same PIDs they had before the deployment, which means their worker processes were never recycled. Only Tenant1Site’s PID changes. That is the evidence that skipIISReset and content-hash detection left the unaffected tenants running untouched.

Step 4: Check deployment logs. Elastic Beanstalk records the output of each custom deployment script. Pull the change-detection lines from the most recent log

aws ssm send-command \ 
  --instance-ids $EB_INSTANCE_ID \ 
  --document-name "AWS-RunPowerShellScript" \ 
  --parameters 'commands=["$f = Get-ChildItem \"C:\\Program Files\\Amazon\\ElasticBeanstalk\\logs\\AWS.DeploymentCommands.*.log\" | Sort-Object LastWriteTime -Descending | Select-Object -First 1; Select-String -Path $f.FullName -Pattern \"unchanged - skipping|changed - running\" | ForEach-Object { $_.Line }"]' \ 
  --output json --query "Command.CommandId" 

For a deployment that changed only Tenant1Site, the log shows:

[INFO] Tenant1Site changed - running install
[INFO] Tenant2Site unchanged - skipping install
[INFO] Tenant3Site unchanged - skipping install

Things to know

  • The first deployment to a new instance touches all tenants because no hash files exist yet. This is a one-time cost; subsequent deployments correctly skip unchanged tenants.
  • Build from a consistent directory. If you switch build directories, the .NET compiler may embed different path metadata in the DLLs, causing hash mismatches. In a CI/CD pipeline, builds always run from the same checkout path, so this is not an issue. You may use DotNet.ReproducibleBuilds to enhance the build reproducibility.
  • Stop the app pool before replacing files. Running ASP.NET Core processes hold locks on DLLs. The install script must stop the app pool and wait before removing files.
  • The restart script will recycle only the individual tenant’s app pool, not run iisreset. A full IIS reset would defeat the purpose of skipIISReset: true.
  • Single-instance environments and environments with all-at-once deployment strategy can further benefit from the skipIISReset option and content hashing strategy, as you can achieve faster deployments and zero-downtime for unaffected tenants.
  • For Monitoring and troubleshooting the Elastic Beanstalk environment, refer to the following AWS documentation:
  • Monitoring environments in AWS Elastic Beanstalk
  • Troubleshooting your AWS Elastic Beanstalk environment

Cleanup

To avoid ongoing charges, empty the S3 bucket and delete the CloudFormation stack:

BUCKET=$(aws cloudformation describe-stacks --stack-name multisite-demo-stack \ 
  --query "Stacks[0].Outputs[?OutputKey=='DeploymentBucketName'].OutputValue" --output text) 
aws s3 rm s3://$BUCKET --recursive 
aws cloudformation delete-stack --stack-name multisite-demo-stack 
aws cloudformation wait stack-delete-complete --stack-name multisite-demo-stack 

Conclusion

In this post, you learned how to achieve zero-downtime deployments for multi-tenant ASP.NET applications on Elastic Beanstalk by combining skipIISReset: true with content-hash change detection and dedicated IIS application pools. Elastic Beanstalk redeploys only the tenants that changed, while all other tenants continue serving requests without interruption.

To get started, clone the source code from GitHub, deploy the CloudFormation template, and follow the walkthrough to verify the zero-downtime behavior in your own environment. For more information about Elastic Beanstalk custom deployments, refer to the deployment manifest documentation.

Nag Shrenik Bandi

Nag Shrenik Bandi

Nag Shrenik Bandi is a Solutions Architect at AWS based in Chicago, Illinois. His focus areas are DevOps, infrastructure automation, and Generative AI on AWS. He also actively engages with the AWS builder community, sharing knowledge on Containers, Serverless, Infrastructure-as-Code, cloud migrations, and Generative AI to help others get started on AWS.

Jose Guay

Jose Guay

Jose is a Senior Technical Account Manager at AWS Enterprise Support, serving customers in the US Financial Services Industry (FSI) vertical. Originally from Guatemala and now based in the US, he specializes in helping enterprise customers architect and operate workloads on AWS, with deep expertise in Microsoft technologies, AWS cloud, and software development.

Prasad Rao

Prasad Rao

Prasad Rao is a Principal Partner Solutions Architect at AWS based in the UK. His focus areas are migrating and modernizing workloads on AWS. He leverages his experience to help AWS Partners across EMEA with their long-term technical enablement to build scalable architectures on AWS. He also mentors diverse people who are new to cloud and would like to get started on AWS.

Sivasekar Elumalai

Sivasekar Elumalai

Siva is a Specialist Solutions Architect in Amazon Web Services based out of Nashville, TN. He has over 15 years of experience specialized in Infrastructure Migration & Modernization focused on Microsoft & VMware workloads. He has helped several Customers & work closely with Partners to achieve their Migration & Modernization goals.

Ty Augustine

Ty Augustine

Ty is a Microsoft Specialist Solutions Architect focused on .NET, SQL Server and Containers. Ty is based in NYC and works closely across diverse industries to accelerate migrations and modernization to the AWS Cloud. Before coming to AWS, Ty was a Microsoft stack software architect for 20+ years.