Nw
Application Servers AWS

.NET Web App Host 10.0.113 on AWS User Guide

| Product: .NET Web App Host 10.0.113 on Ubuntu 24.04 on AWS

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 /health probe needs HTTP Basic authentication as admin with 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 overwrites X-Forwarded-For and X-Real-IP with the real client address. The sample app trusts forwarded headers from 127.0.0.1 only.
  • 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 Host header.
  • Sandboxed and fail closed. The app runs as the unprivileged aspnet user 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/aspnet is 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 main component, so apt and 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:

The cloudimg .NET Web App Host landing page: server time, requests served and uptime computed on this request, the .NET 10.0.13 runtime, Ubuntu 24.04.5 LTS, and the request path with the configured public URL, the client address taken from the connection and the loopback Kestrel bind address

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

The lower half of the landing page: the request path, the storage table (application directory, sample source, Data Protection key ring and the free space of the 20 GiB app volume) and the endpoints /api/info, /api/health, /absolute-redirect and /health

/api/info returns the same facts as JSON:

The /api/info JSON in a browser: product, runtime .NET 10.0.13, CLR version, operating system, processor count, request counter, app volume space, the configured selfUrl, the request host and scheme, and the client IP

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:

A browser showing the deployed HelloApp at the instance URL: Hello from my own ASP.NET Core app, scheme https, client 127.0.0.1

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-deploy refuses 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 a ReadWritePaths= line with sudo systemctl edit aspnet-app.
  • Use ForwardedHeaders as above so your app sees the real client address and https. nginx always sends the configured host name, and the app's AllowedHosts is 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.conf and run sudo 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.