.NET Web App Host 10.0.113 on AWS User Guide
Overview
This guide covers launching and using the .NET Web App Host 10.0.113 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg app stacks and runtimes listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.
The image is a ready to run host for ASP.NET Core web applications. It is not a bare runtime: the .NET SDK and the matching shared runtime are installed, a real ASP.NET Core application is already compiled on the image and serving under Kestrel, and nginx sits in front of it as the only service on the network. You build and publish your own application on the instance with the same SDK and swap it in with one command.
What the cloudimg image adds:
- HTTPS from the first boot. nginx serves the app on port 443 with a certificate generated on your instance's first boot, and port 80 redirects to HTTPS. No certificate or key is baked into the image.
- A per instance admin password. Every path except the
/healthprobe needs HTTP Basic authentication asadminwith a password generated on first boot, stored as a bcrypt hash for nginx and in plain text only in a file root can read. No two instances share one, and one command replaces it. - Loopback only, behind nginx. Kestrel listens on
127.0.0.1:5000. nginx answers only for your instance's own host names, hands the app the configured host name (never the one a client sends), and overwritesX-Forwarded-ForandX-Real-IPwith the real client address. The sample app trusts forwarded headers from127.0.0.1only. - Host from configuration. The app's public URL is set at first boot from the instance's EC2 public DNS name (or a name you choose), so absolute links and redirects can never be pointed elsewhere by a forged
Hostheader. - Sandboxed and fail closed. The app runs as the unprivileged
aspnetuser under a locked down systemd unit, and neither the app nor nginx can reach the instance metadata service. Neither starts until first boot has generated the certificate and the password. - Your apps on their own disk.
/srv/aspnetis a dedicated 20 GiB EBS volume (the published app, its source, the Data Protection key ring and logs) that you can resize independently of the operating system disk. The Data Protection key ring is created by the app on your instance; none ships in the image. - .NET from the Ubuntu archive, the MIT licensed build Canonical ships in Ubuntu's
maincomponent, soaptand unattended upgrades keep it patched, on a fully patched Ubuntu 24.04 LTS at build time.
What is on the image: .NET SDK 10.0.113 with the ASP.NET Core and .NET runtimes 10.0.13 (/usr/lib/dotnet, the dotnet command on every user's PATH), the sample app CloudimgAppHost (published in /srv/aspnet/app, source in /srv/aspnet/src/CloudimgAppHost), the aspnet-app.service, dotnet-firstboot.service and nginx.service units, the helpers aspnet-app-deploy, dotnet-rotate-password and dotnet-set-hostname, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent. .NET CLI telemetry is switched off for every user. .NET 10 is a long term support release, supported until November 2028.
Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large
.NET and ASP.NET are trademarks of the Microsoft group of companies. cloudimg is not affiliated with, endorsed by, or sponsored by Microsoft.
Prerequisites
- An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet (or a private route you use for management).
- A security group that allows TCP 22 (SSH) from your own networks only, and TCP 443 (HTTPS) and TCP 80 (redirects to HTTPS) from the clients your app serves. Nothing else needs to be open: Kestrel listens on loopback only.
Step 1: Launch the instance
In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick .NET Web App Host 10.0.113 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (or larger for heavier apps), your key pair, and a security group as above. The image attaches its 20 GiB app volume automatically; you can make it bigger at launch.
With the AWS CLI (replace the values in angle brackets):
aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
--instance-type m5.large --key-name <your-key-pair> \
--security-group-ids <your-security-group> --subnet-id <your-subnet> \
--metadata-options HttpTokens=required,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=dotnet-host}]'
Step 2: Connect to your instance
| Version | SSH user |
|---|---|
| .NET Web App Host 10.0.113 on Ubuntu 24.04 | ubuntu |
From your own machine:
ssh -i <your-key.pem> ubuntu@<public-ip>
Step 3: Check the services
First boot generates the certificate, the app's configuration and the admin password, then the app and nginx start:
systemctl is-active aspnet-app nginx
sudo test -f /var/lib/cloudimg/dotnet-firstboot.done && echo FIRSTBOOT_DONE
dotnet --list-sdks
dotnet --list-runtimes
active
active
FIRSTBOOT_DONE
10.0.113 [/usr/lib/dotnet/sdk]
Microsoft.AspNetCore.App 10.0.13 [/usr/lib/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 10.0.13 [/usr/lib/dotnet/shared/Microsoft.NETCore.App]
nginx listens on ports 80 and 443 on every address; Kestrel (the dotnet process) listens on loopback only:
sudo ss -lntp | awk 'NR>1 {print $4, $6}' | sed -E 's/pid=[0-9]+,fd=[0-9]+/.../g' | sort -u
0.0.0.0:22 users:(("sshd",...),("systemd",...))
0.0.0.0:443 users:(("nginx",...),("nginx",...),("nginx",...))
0.0.0.0:80 users:(("nginx",...),("nginx",...),("nginx",...))
127.0.0.1:5000 users:(("dotnet",...))
127.0.0.53%lo:53 users:(("systemd-resolve",...))
127.0.0.54:53 users:(("systemd-resolve",...))
[::]:22 users:(("sshd",...),("systemd",...))
[::]:443 users:(("nginx",...),("nginx",...),("nginx",...))
[::]:80 users:(("nginx",...),("nginx",...),("nginx",...))
Step 4: Your instance's URL and admin password
sudo cat /root/dotnet-credentials.txt
The file is readable by root only:
# .NET Web App Host 10.0.113 - generated on first boot by dotnet-firstboot.service
# The admin password is unique to this instance; none was baked into the image. Keep this file secret.
ASPNET_APP_URL=https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/
ASPNET_ADMIN_USER=admin
ASPNET_ADMIN_PASSWORD=<ASPNET_ADMIN_PASSWORD>
ASPNET_TLS_CERT=/etc/nginx/tls/dotnet.crt
ASPNET_CERT_SHA256=<sha256-fingerprint>
ASPNET_APP_DIR=/srv/aspnet/app
#
# nginx (HTTPS, HTTP Basic auth) is the only public entry point; the app runs under Kestrel on 127.0.0.1:5000.
# /health is an unauthenticated probe answered by nginx. Your app is deployed with: sudo aspnet-app-deploy
# New admin password: sudo dotnet-rotate-password
# Change the name it serves as: sudo dotnet-set-hostname <your-dns-name>
# User guide: https://www.cloudimg.co.uk/guides/net-web-app-host-10-0-113-on-ubuntu-24-04-aws/
The URL uses the instance's public DNS name from EC2 instance metadata. The certificate also names the public and private IP addresses, so https://<public-ip>/ works too. To serve your own DNS name, see Step 9.
Step 5: Open the app
From your own computer, open the ASPNET_APP_URL in a browser. The certificate is self signed, so the browser asks you to confirm it the first time; then sign in as admin with the password from Step 4. The landing page is rendered by the ASP.NET Core app on every request:

Further down it shows where everything lives on the app volume and the endpoints the sample app serves:

/api/info returns the same facts as JSON:

On the instance, the same checks with curl. /health is answered by nginx without a password, anything else needs it. The password is sent from the root only file through curl's configuration on standard input, so it never appears on a command line:
H=$(sudo sed -n 's#^ASPNET_APP_URL=https://\(.*\)/$#\1#p' /root/dotnet-credentials.txt)
curl -s --cacert /etc/nginx/tls/dotnet.crt --resolve "$H:443:127.0.0.1" "https://$H/health"
curl -s -o /dev/null -w '%{http_code}\n' --cacert /etc/nginx/tls/dotnet.crt --resolve "$H:443:127.0.0.1" "https://$H/api/info"
sudo sed -n 's/^ASPNET_ADMIN_PASSWORD=\(.*\)/user = "admin:\1"/p' /root/dotnet-credentials.txt | curl -s --cacert /etc/nginx/tls/dotnet.crt --resolve "$H:443:127.0.0.1" -K - "https://$H/api/info"; echo
ok
401
{
"product": "cloudimg .NET Web App Host",
"runtime": ".NET 10.0.13",
"aspNetCoreAssemblyVersion": "10.0.0.0",
"clrVersion": "10.0.13",
"osDescription": "Ubuntu 24.04.5 LTS",
"architecture": "X64",
"processorCount": 2,
"serverTimeUtc": "2026-10-20T05:36:01Z",
"startedUtc": "2026-10-20T05:30:06Z",
"uptimeSeconds": 355,
"requestsServed": 1,
"dataVolume": "/srv/aspnet",
"dataVolumeSpace": "18.5 GiB free of 19.5 GiB",
"selfUrl": "https://ec2-<public-ip-dashed>.compute-1.amazonaws.com",
"requestHost": "ec2-<public-ip-dashed>.compute-1.amazonaws.com",
"requestScheme": "https",
"clientIp": "127.0.0.1"
}
Port 80 redirects to HTTPS on the configured name, and a host name the instance does not serve gets no answer at all (nginx closes the connection, shown by curl as 000):
H=$(sudo sed -n 's#^ASPNET_APP_URL=https://\(.*\)/$#\1#p' /root/dotnet-credentials.txt)
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' --resolve "$H:80:127.0.0.1" "http://$H/api/info"
curl -sk -o /dev/null -w '%{http_code}\n' --resolve "other.example:443:127.0.0.1" https://other.example/ || true
301 https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/api/info
000
Step 6: Check the security posture
The shipped checker proves the whole front door with this instance's own password: verified TLS, the redirect, host pinning, authentication, the configured public URL, and that Kestrel refuses connections on the private address while nginx answers on it:
sudo /usr/local/sbin/dotnet-credential-round-trip.sh
round-trip: ok 80 -> 301 https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/api/info
round-trip: ok verified TLS /health (no auth) -> 200
round-trip: ok unknown Host on 443 dropped -> 000
round-trip: ok anonymous / -> 401
round-trip: ok anonymous /api/info -> 401
round-trip: ok wrong password -> 401
round-trip: ok instance password / -> 200
round-trip: ok app runs the pinned .NET 10.0.13
round-trip: ok selfUrl is the configured https://ec2-<public-ip-dashed>.compute-1.amazonaws.com
round-trip: ok absolute redirect -> https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/
round-trip: ok Kestrel listens on 127.0.0.1:5000 only
round-trip: ok <private-ip>:5000 refused
round-trip: ok positive control https://<private-ip>/health -> 200
round-trip: RESULT OK
The app runs as aspnet, and the app, nginx and the first boot unit are all denied the instance metadata service (169.254.169.254), so a server side request forgery in your app cannot reach instance credentials:
ps -o user=,args= -C dotnet
systemctl show -p IPAddressDeny aspnet-app nginx dotnet-firstboot
aspnet /usr/bin/dotnet /srv/aspnet/app/CloudimgAppHost.dll
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
Step 7: Build and deploy your own app
The SDK on the image builds and publishes apps as any user. Create an app in your home directory (the web template needs no packages from nuget.org), give it a page that shows what reaches it through nginx, and publish it:
mkdir -p ~/apps && cd ~/apps && dotnet new web -o HelloApp --no-update-check
cat > ~/apps/HelloApp/Program.cs <<'CS'
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
// nginx on 127.0.0.1 sets X-Forwarded-For (the real client) and X-Forwarded-Proto (https).
builder.Services.Configure<ForwardedHeadersOptions>(o =>
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto);
var app = builder.Build();
app.UseForwardedHeaders();
app.MapGet("/", (HttpContext ctx) =>
$"Hello from my own ASP.NET Core app: scheme {ctx.Request.Scheme}, client {ctx.Connection.RemoteIpAddress}\n");
app.Run();
CS
cd ~/apps/HelloApp && dotnet publish -c Release -o out --nologo
The template "ASP.NET Core Empty" was created successfully.
Processing post-creation actions...
Restoring /home/ubuntu/apps/HelloApp/HelloApp.csproj:
Determining projects to restore...
Restored /home/ubuntu/apps/HelloApp/HelloApp.csproj (in 146 ms).
Restore succeeded.
Determining projects to restore...
All projects are up-to-date for restore.
HelloApp -> /home/ubuntu/apps/HelloApp/bin/Release/net10.0/HelloApp.dll
HelloApp -> /home/ubuntu/apps/HelloApp/out/
aspnet-app-deploy copies the published output into /srv/aspnet/app, points aspnet-app.service at your entry assembly and restarts it. Your app inherits the unit's loopback bind (ASPNETCORE_URLS=http://127.0.0.1:5000), its sandbox and the metadata block, and nginx keeps HTTPS and the admin password in front of it:
sudo aspnet-app-deploy ~/apps/HelloApp/out HelloApp.dll
H=$(sudo sed -n 's#^ASPNET_APP_URL=https://\(.*\)/$#\1#p' /root/dotnet-credentials.txt)
sudo sed -n 's/^ASPNET_ADMIN_PASSWORD=\(.*\)/user = "admin:\1"/p' /root/dotnet-credentials.txt | curl -s --cacert /etc/nginx/tls/dotnet.crt --resolve "$H:443:127.0.0.1" -K - "https://$H/"
aspnet-app-deploy: HelloApp.dll is running behind nginx on 127.0.0.1:5000
Hello from my own ASP.NET Core app: scheme https, client 127.0.0.1
In a browser the same page now answers at your instance's URL:

Things to know when you deploy your own app:
- Publish framework dependent (the default): the app runs on the shared runtime from Ubuntu, which unattended upgrades keep patched.
aspnet-app-deployrefuses a self contained publish. - The app can write only to
/srv/aspnet/keys(the Data Protection key ring) and/srv/aspnet/logs; the rest of the file system is read only to it. To give it another writable directory, add aReadWritePaths=line withsudo systemctl edit aspnet-app. - Use
ForwardedHeadersas above so your app sees the real client address andhttps. nginx always sends the configured host name, and the app'sAllowedHostsis set to the instance's names in/etc/aspnet-app/app.env. - To serve the app without the admin password (a public website), remove the two directives from
/etc/nginx/dotnet-auth.confand runsudo systemctl reload nginx. HTTPS, host pinning and the client address handling stay in place.
To go back to the sample app, publish it from its source on the app volume:
cp -r /srv/aspnet/src/CloudimgAppHost ~/apps/ && cd ~/apps/CloudimgAppHost && dotnet publish -c Release -o out --nologo
sudo aspnet-app-deploy ~/apps/CloudimgAppHost/out CloudimgAppHost.dll
Determining projects to restore...
Restored /home/ubuntu/apps/CloudimgAppHost/CloudimgAppHost.csproj (in 108 ms).
CloudimgAppHost -> /home/ubuntu/apps/CloudimgAppHost/bin/Release/net10.0/CloudimgAppHost.dll
CloudimgAppHost -> /home/ubuntu/apps/CloudimgAppHost/out/
aspnet-app-deploy: CloudimgAppHost.dll is running behind nginx on 127.0.0.1:5000
Step 8: Replace the admin password
sudo dotnet-rotate-password
sudo stat -c '%a %U:%G %n' /root/dotnet-credentials.txt /etc/nginx/.aspnet_htpasswd
sudo cut -c1-12 /etc/nginx/.aspnet_htpasswd
dotnet-rotate-password: new admin password written (see /root/dotnet-credentials.txt); nginx reloaded
600 root:root /root/dotnet-credentials.txt
640 root:www-data /etc/nginx/.aspnet_htpasswd
admin:$2y$12
The new password is in /root/dotnet-credentials.txt; nginx stores only its bcrypt hash. The old password stops working at once.
Step 9: Your own DNS name and certificate
Point a DNS record at the instance (an Elastic IP keeps the address stable), then tell the instance its name. This mints a new self signed certificate for the name, pins nginx to it, and updates the app's public URL and AllowedHosts:
sudo dotnet-set-hostname app.example.com
For a publicly trusted certificate, put an Application Load Balancer with an AWS Certificate Manager certificate in front of the instance (target port 443, health check path /health), or replace /etc/nginx/tls/dotnet.crt and dotnet.key with your own certificate and key and reload nginx.
Step 10: Logs
journalctl -u aspnet-app -n 4 --no-pager -o cat
sudo tail -n 2 /var/log/nginx/access.log | cut -c1-120
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /srv/aspnet/app
127.0.0.1 - admin [20/Oct/2026:05:37:34 +0000] "GET / HTTP/1.1" 200 78 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15
127.0.0.1 - admin [20/Oct/2026:05:37:48 +0000] "GET /api/info HTTP/1.1" 200 637 "-" "curl/8.5.0"
nginx logs the real client address taken from the connection; a client cannot change it with an X-Forwarded-For header.
Step 11: Server components
| Component | Where |
|---|---|
| .NET SDK 10.0.113, ASP.NET Core and .NET runtimes 10.0.13 | /usr/lib/dotnet (Ubuntu packages dotnet-sdk-10.0, aspnetcore-runtime-10.0) |
Published app (run by aspnet-app.service as aspnet) |
/srv/aspnet/app |
| Sample source | /srv/aspnet/src/CloudimgAppHost |
| Data Protection key ring, logs | /srv/aspnet/keys, /srv/aspnet/logs |
| App configuration (public URL, AllowedHosts, entry assembly) | /etc/aspnet-app/app.env |
| nginx site, auth and host includes | /etc/nginx/sites-available/cloudimg-aspnet, /etc/nginx/dotnet-auth.conf, /etc/nginx/dotnet-host.conf |
| Certificate and key | /etc/nginx/tls/dotnet.crt, /etc/nginx/tls/dotnet.key |
| Build provenance (package versions and sha256) | /opt/cloudimg/dotnet-versions.txt, /opt/cloudimg/dotnet-licence-provenance.txt |
The .NET packages come from the Ubuntu archive and are MIT licensed; the licence ships with the binaries:
head -3 /usr/lib/dotnet/LICENSE.txt
grep -E '^(dotnet-sdk-10.0|aspnetcore-runtime-10.0|PINNED)' /opt/cloudimg/dotnet-versions.txt | cut -c1-120
grep TELEMETRY /etc/environment
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
aspnetcore-runtime-10.0 10.0.13-0ubuntu1~24.04.1 sha256=33f942d6444eafe4b4e1e75d856acb00c10ed32e77d1db76efe62e36b20a11c0
dotnet-sdk-10.0 10.0.113-0ubuntu1~24.04.1 sha256=af10918d841dacf97dc7d599536723ad3cac1879dd46b5fa3f081c021cbe967b pool=p
PINNED_SDK=10.0.113
PINNED_ASPNETCORE_RUNTIME=10.0.13
PINNED_NETCORE_RUNTIME=10.0.13
DOTNET_CLI_TELEMETRY_OPTOUT=1
Step 12: Updating .NET
.NET security releases for Ubuntu 24.04 arrive through noble-security, and unattended upgrades are switched on, so the SDK and runtimes are patched like any other package:
apt-cache policy dotnet-sdk-10.0 | head -4
systemctl is-enabled unattended-upgrades
dotnet-sdk-10.0:
Installed: 10.0.113-0ubuntu1~24.04.1
Candidate: 10.0.113-0ubuntu1~24.04.1
Version table:
enabled
After a runtime update, restart your app so it loads the new runtime: sudo systemctl restart aspnet-app. To update by hand: sudo apt-get update && sudo apt-get install --only-upgrade dotnet-sdk-10.0 aspnetcore-runtime-10.0.
Support
cloudimg provides 24/7 technical support for this image by email and live chat: support@cloudimg.co.uk. See the app stacks and runtimes listing guide for the other versions in this listing.