DEV Community

Cover image for From .NET Framework on Windows to .NET 5 on Linux (AWS): what I migrated, how, and why
Simone Riggi
Simone Riggi

Posted on

From .NET Framework on Windows to .NET 5 on Linux (AWS): what I migrated, how, and why

Overview

I recently completed a deep migration that touched code, infrastructure, build, and runtime. The starting point was a Windows-only .NET Framework class library, bundled into a monolithic deployment and manually hosted on a static server. It depended on Windows-specific executables/libraries, had hard-coded configuration, no CI/CD, and no elasticity.
The target was a cloud-native, Linux-hosted .NET 5 Web API running on EC2 behind an Auto Scaling Group, configured via AWS Systems Manager Parameter Store, managed by systemd, and deployed automatically through TeamCity.

Note: I had to use .NET 5 ** to guarantee the backward compatibility of legacy code and third-party libraries** not compatible with .NET6+.

Why the change?

  • Cost: Linux vs Windows hosting.

  • Modernization: move toward .NET (Core) and decouple from Win32 APIs.

  • Scalability: ASG with rolling/instance refresh.

  • Velocity: CI/CD, reproducible builds.

  • Reliability: systemd supervision, health checks, structured logging.

Before → After

Before

  • .NET Framework class library (MVC-era Web API glue).

  • Windows-only dependencies (EXEs/DLLs).

  • Manual deploys, single box, no auto-scaling.

  • Config in web.config/app.config, minimal environment awareness.

After

  • .NET 5 Web API (Kestrel), Linux-first.

  • Native dependencies replaced or repackaged for Linux.

  • CI/CD with TeamCity → S3 artifact → ASG instance refresh.

  • Config from Parameter Storeappsettings.json at boot.

  • Service managed by systemd with logs in journalctl

Step 1 — Port to .NET 5 Web API

I created a new .NET 5 Web API host and moved all registration/configuration (Autofac, Swagger, formatters, handlers) into Startup/Program:

public class Program
{
    public static void Main(string[] args) =>
        CreateHostBuilder(args).Build().Run();

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(web =>
            {
                // Listen on any IP; can be overridden with ASPNETCORE_URLS or --urls
                web.UseKestrel()
                   .UseStartup<Startup>();
            });
}
Enter fullscreen mode Exit fullscreen mode

Key changes:

  • Handlers → Middleware: old DelegatingHandler/MessageHandler logic became ASP.NET Core middleware.

  • Configuration: moved from AppSettings.config to appsettings.json + IConfiguration/IOptions<T>.

  • Swagger: re-registered via AddSwaggerGen() and UseSwaggerUI().

Note: .NET 5 does not support implicit global usings (that came in .NET 6). Keep your usings explicit.

Step 2 — Make it build and run on Linux

Set the runtime identifier and (optionally) self-contained publish in the Web API .csproj:

<PropertyGroup>
  <TargetFramework>net5.0</TargetFramework>
  <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  <PublishSingleFile>true</PublishSingleFile>
  <SelfContained>true</SelfContained>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Publish:

dotnet publish -c Release -r linux-x64
Enter fullscreen mode Exit fullscreen mode

Tip: If you use third-party libraries, ensure the Linux variants are included next to your binary and have execute permission where required.

Step 3 — Handle Windows-only dependencies

Some functionality relied on Windows executables (e.g., structured output converters). On Linux:

  • Replace with vendor’s Linux binaries.

  • Ensure they’re part of the artifact (mark files as Copy to Output Directory).

  • Mark them executable at deploy time:

chmod +x /var/opt/net-webapi/Libraries/library
Enter fullscreen mode Exit fullscreen mode

If a native .so must be found at runtime, export LD_LIBRARY_PATH in the service unit:

Environment=LD_LIBRARY_PATH=/var/opt/net-webapi/Libraries:$LD_LIBRARY_PATH

Enter fullscreen mode Exit fullscreen mode

Also normalize path separators in code; prefer Path.Combine and avoid C:\... style paths.

Step 4 — Service on Linux with systemd

Create and add net-webapi.service in the .NET project:

[Unit]
Description=WebAPI service 
After=network.target

[Service]
WorkingDirectory=/var/opt/net-webapi
ExecStart=/var/opt/net-webapi/NET.WebAPI --urls http://0.0.0.0:80
Restart=always
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=net-webapi
User=www-data
Group=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
# Optional: for native libs / external tools
Environment=LD_LIBRARY_PATH=/var/opt/net-webapi/lib:$LD_LIBRARY_PATH
# Allow www-data to bind :80 without root (or use a reverse proxy)
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.targe
Enter fullscreen mode Exit fullscreen mode

Step 5 — CI/CD with TeamCity → S3 → ASG refresh

Artifacts (example):

noodls.utilities/NET.WebAPI/bin/%Configuration%/net5.0/linux-x64 => NET/NET.WebAPI

Enter fullscreen mode Exit fullscreen mode

Upload to S3 (build step):

<artifactsPath> => <bucket>/<env>/NET/NET.WebAPI/package.zip
<artifactsPath> => <bucket>/<env>/NET/NET.WebAPI/PreviousVersions/package_%build.number%.zip
Enter fullscreen mode Exit fullscreen mode

Instance refresh (PowerShell in TeamCity agent, or Bash):

$envs = "%AutoscalingGroups%".Split([Environment]::NewLine)
foreach($env in $envs){
  $parts = $env.Split(';')
  if($parts.Count -lt 2){ continue }
  $asg = $parts[0].Trim(); $region = $parts[1].Trim()
  aws autoscaling start-instance-refresh `
    --auto-scaling-group-name $asg `
    --preferences '{\"InstanceWarmup\":300,\"MinHealthyPercentage\":100}' `
    --region $region
}

Enter fullscreen mode Exit fullscreen mode

Example variable:

ASG-NET-WEBAPI;us-east-1
Enter fullscreen mode Exit fullscreen mode

Step 6 — Launch Template & User Data (EC2)

Launch template highlights:

  • AMI: choose a Linux image compatible with your runtime.

    For .NET 5 (OpenSSL 1.1), Ubuntu 20.04 (focal) or Amazon Linux 2 are safer than 22.04+ unless you add libssl1.1. Better yet, plan to upgrade to .NET 8.

  • IAM Role: allow ssm:GetParameter and S3 read.

  • Instance metadata:

    • Enable metadata & allow tags in metadata.
  • Tags (key/value):

    • appsettings-parameter-name = net-webapi.appsettings
    • log4net-parameter-name = net-webapi.log4net

User Data (hardened & corrected):

#!/bin/bash
set -euo pipefail

# Update base
apt-get update -y
apt-get install -y unzip curl python3 jq

# Install AWS CLI v2
curl -sS "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
/tmp/aws/install

# (Optional) Install chrony
apt-get install -y chrony

# Prepare app folder
install -d -o www-data -g www-data /var/opt/net-webapi

# Download package from S3
aws s3 cp "s3://<bucket>/<ENV>/NET/NET.WebAPI/package.zip" /tmp/package.zip
unzip -q /tmp/package.zip -d /var/opt/net-webapi
chown -R www-data:www-data /var/opt/net-webapi
chmod +x /var/opt/net-webapi/NET.WebAPI || true

# If you ship native tools, ensure they're executable
# chmod +x /var/opt/net-webapi/Exporters/AprysePDFNet/lib/StructuredOutput || true

# Fetch Parameter Store values (instance tags carry the parameter names)
CONFIG_PARAM=$(curl -fsS http://169.254.169.254/latest/meta-data/tags/instance/appsettings-parameter-name)
LOG4NET_PARAM=$(curl -fsS http://169.254.169.254/latest/meta-data/tags/instance/log4net-parameter-name)

aws ssm get-parameter --name "$CONFIG_PARAM" --with-decryption --region us-east-1 \
  | jq -r '.Parameter.Value' > /var/opt/net-webapi/appsettings.json

aws ssm get-parameter --name "$LOG4NET_PARAM" --with-decryption --region us-east-1 \
  | jq -r '.Parameter.Value' > /var/opt/net-webapi/log4net.config

chown www-data:www-data /var/opt/net-webapi/appsettings.json /var/opt/net-webapi/log4net.config

# Install systemd unit
cp /var/opt/net-webapi/net-webapi.service /etc/systemd/system/net-webapi.service
systemctl daemon-reload
systemctl enable net-webapi
systemctl start net-webapi

Enter fullscreen mode Exit fullscreen mode

Avoid weakening TLS (e.g., forcing TLS 1.0 in openssl.cnf). Instead, keep modern defaults and fix the database side or client library if you hit handshake issues.

Step 7 — Database connectivity (TLS gotchas)

If you see errors like “SSL/TLS handshake failed”:

  • Prefer Microsoft.Data.SqlClient in .NET, which has better TLS support than the legacy System.Data.SqlClient.

  • Ensure SQL Server supports TLS 1.2+ with a proper certificate.

  • As a temporary diagnostic only, try Encrypt=False or TrustServerCertificate=True in the connection string to confirm TLS is the issue—then fix certificates and revert to secure defaults.


Step 8 — Observability & permissions

  • Use journalctl -u net-webapi -f to tail logs.

  • If the app writes under /var/opt/net-webapi/temp, ensure the folder exists and is writable by the service user:

install -d -o www-data -g www-data /var/opt/net-webapi/temp
Enter fullscreen mode Exit fullscreen mode
  • For “Permission denied to execute external module” errors, chmod +x the binary and verify the directory mount options allow execution (noexec would block it).

Step 9 — TeamCity artifact hygiene

Mark non-code files (native tools, configs, templates) as “Copy to Output Directory” → “Copy if newer” so they appear in the published output and the TeamCity artifact.


What I’d improve next

  • Upgrade to .NET 8 LTS (security & runtime support; avoids OpenSSL 1.1 headaches).

  • Health checks (/health) + Auto Scaling based on ALB target health.

  • CodeDeploy or SSM Distributor instead of ad-hoc unzip for richer deployments.

  • Secret management via Parameter Store (SecureString) or Secrets Manager with IAM.


Final notes

This migration wasn’t just a “retarget the framework” exercise. It required:

  • Reworking hosting and middleware.

  • Replacing/porting Windows-specific dependencies.

  • Designing a Linux-friendly packaging and service model.

  • Building CI/CD and immutable infrastructure via ASG + Launch Templates.

  • Tightening permissions, TLS, and observability.

Top comments (0)