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 Store →
appsettings.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>();
});
}
Key changes:
Handlers → Middleware: old
DelegatingHandler
/MessageHandler
logic became ASP.NET Core middleware.Configuration: moved from
AppSettings.config
toappsettings.json
+IConfiguration/IOptions<T>
.Swagger: re-registered via
AddSwaggerGen()
andUseSwaggerUI()
.
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>
Publish:
dotnet publish -c Release -r linux-x64
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
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
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
Step 5 — CI/CD with TeamCity → S3 → ASG refresh
Artifacts (example):
noodls.utilities/NET.WebAPI/bin/%Configuration%/net5.0/linux-x64 => NET/NET.WebAPI
Upload to S3 (build step):
<artifactsPath> => <bucket>/<env>/NET/NET.WebAPI/package.zip
<artifactsPath> => <bucket>/<env>/NET/NET.WebAPI/PreviousVersions/package_%build.number%.zip
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
}
Example variable:
ASG-NET-WEBAPI;us-east-1
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 addlibssl1.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
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 legacySystem.Data.SqlClient
.Ensure SQL Server supports TLS 1.2+ with a proper certificate.
As a temporary diagnostic only, try
Encrypt=False
orTrustServerCertificate=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
- 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)