Nw
Application Servers Azure

cloudimg .NET Web App Host on Ubuntu 24.04 on Azure User Guide

| Product: cloudimg .NET Web App Host on Ubuntu 24.04 LTS on Azure

Overview

This 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 and pinned, a real ASP.NET Core application is already compiled and serving under Kestrel, and nginx sits in front of it as the single public entry point. Everything you own — the published application, its source, the Data Protection key ring and its logs — lives on a dedicated data volume mounted at /srv/aspnet, separate from the operating system disk.

Where the .NET binaries come from, and why it matters. .NET is open source under the MIT licence, but Microsoft's own Windows builds ship under the Microsoft .NET Library License, whose distribution terms are not compatible with redistributing a runtime as a standalone image. This image sidesteps that question entirely: it installs .NET only from the Ubuntu archive (dotnet-sdk-10.0 and its runtimes, from noble-updates/main and noble-security/main), which Canonical builds from the MIT-licensed source. Those packages are in Ubuntu's main component, so they carry Canonical's licence review and receive security updates through the normal apt channel. Nothing from packages.microsoft.com is installed, and the image is built with an automated gate that refuses to proceed if that ever changes.

Read this before you expose the VM. The application is bound to 127.0.0.1:5000 and cannot be reached from the network directly. nginx on port 80 is the only public listener, and it requires HTTP Basic authentication on every path except /health, using a password generated uniquely on the first boot of every VM. Neither nginx nor the application starts at all until that first boot has completed, so there is no window in which the machine is reachable without a credential, and no default password exists in the image.

What is included:

  • .NET SDK 10.0.110 and the .NET / ASP.NET Core 10.0.10 runtimes, from the Ubuntu 24.04 archive (main), pinned and checksum-verified at build time
  • .NET 10 is an LTS release, supported until November 2028
  • A working ASP.NET Core application, compiled on the image, running under Kestrel bound to 127.0.0.1:5000 only
  • A dedicated aspnet service account with NoNewPrivileges, ProtectSystem, ProtectHome and a restricted writable path set
  • nginx on port 80 as the only public listener, enforcing HTTP Basic authentication on every path
  • A per-VM password generated on first boot and recorded in a root-only file
  • A per-VM ASP.NET Core Data Protection key ring, created on first boot — never baked into the image
  • A dedicated 20 GiB data volume at /srv/aspnet for your application, its source and its keys
  • An unauthenticated /health endpoint for Azure Load Balancer health probes
  • 24/7 cloudimg support

.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 active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a reasonable starting point; size up for heavier applications. NSG inbound: allow 22/tcp from your management network and 80/tcp for the application. The image serves plain HTTP on port 80; for production, terminate TLS in front of it with your own domain.

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for cloudimg .NET Web App Host, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTP (80). Then Review + create -> Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group my-resource-group \
  --name my-dotnet-host \
  --image cloudimg:dotnet:default:latest \
  --size Standard_B2s \
  --storage-sku StandardSSD_LRS \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_rsa.pub \
  --public-ip-sku Standard

az vm open-port --resource-group my-resource-group --name my-dotnet-host --port 80

Accept the image terms once per subscription before the first deployment:

az vm image terms accept --publisher cloudimg --offer dotnet --plan default

Step 3 - Retrieve the per-VM password

Every VM generates its own password on first boot. SSH in and read it:

sudo cat /root/dotnet-credentials.txt

You will see the application URL, the username admin, and the password unique to this machine. The file is 0600 root:root, so only root can read it.

Retrieving the per-VM credentials, the file permissions, and the first-boot gate the services wait on

Confirm the permissions and that first boot completed:

sudo stat -c '%a %U:%G %n' /root/dotnet-credentials.txt
ls -l /var/lib/cloudimg/

Step 4 - Verify the deployment

Check the three units are running and that the listeners are where they should be:

systemctl is-active aspnet-app nginx dotnet-firstboot
ss -ltnp | grep -E ':(80|5000)'

nginx listens on 0.0.0.0:80. The application (dotnet) listens on 127.0.0.1:5000 only — that is the whole point of the architecture, and Step 7 proves it.

The unauthenticated health probe should answer immediately:

curl -s http://127.0.0.1/health

The three services, the listener table showing Kestrel bound to loopback only, and the live proof that port 5000 is refused on the routable address while nginx answers on port 80

Step 5 - Open the application in a browser

Browse to http://<your-vm-public-ip>/. Your browser will prompt for a username and password — use admin and the password from Step 3.

The page you land on is rendered on the server, on that request, by the ASP.NET Core application. The three values at the top are computed live, so reloading the page advances the request counter and moves the clock — that is the simplest proof the runtime is genuinely executing your code rather than serving a static file.

The application landing page, server-rendered, showing the live request counter, server time and uptime alongside the pinned .NET 10.0.10 runtime

Scrolling down shows the request path the response travelled, including the exact Host header nginx forwarded and the loopback address Kestrel is bound to.

The request path section, showing the absolute URL the application emitted, the Host header it received, and the loopback-only Kestrel bind address

Below that are the storage locations on the dedicated data volume, and the endpoints the sample application exposes.

The storage section showing the data volume and its free space, and the list of application endpoints

Step 6 - The JSON API

The same facts are available as JSON, which is useful for monitoring and for confirming what the machine is actually running:

curl -s -u "admin:<ASPNET_ADMIN_PASSWORD>" http://127.0.0.1/api/info

The /api/info endpoint returning the runtime, host and storage facts as JSON

Note the selfUrl field. It is built from the Host header of your request, so it always reflects the address you used to reach the machine, including a non-standard port if you are behind a proxy or a tunnel. nginx is configured to forward $http_host rather than $host precisely so that this stays correct — $host silently discards the port, which would make every absolute URL the application emits unreachable for anyone not on port 80.

Step 7 - Confirm the security posture

This is worth doing yourself rather than taking on trust. First, an unauthenticated request must be refused:

curl -s -o /dev/null -w 'anonymous request -> HTTP %{http_code}\n' http://127.0.0.1/

You should see 401. Now prove the application port is not exposed. Connect to the Kestrel port on this VM's own routable address:

curl -s -m 5 "http://$(hostname -I | awk '{print $1}'):5000/" ; echo "curl exit=$? (7 = connection refused)"

That refusal only means something if the address itself is live, so check the control — the same address, on nginx's port:

curl -s -o /dev/null -w 'same address, port 80 -> HTTP %{http_code}\n' "http://$(hostname -I | awk '{print $1}')/"

401 from port 80 and a refused connection on port 5000, from the same address, together prove the application is reachable only through the authenticating proxy.

Finally, confirm the services are gated on the first-boot marker, which is what guarantees no listener exists before a password does:

grep -h ConditionPathExists /etc/systemd/system/aspnet-app.service /etc/systemd/system/nginx.service.d/10-cloudimg-dotnet.conf

Step 8 - Build and deploy your own application

The SDK is installed, so you can build on the machine you deploy to. Scaffold a new ASP.NET Core application, publish it to the data volume, and run it on a spare port to check it works:

export DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_GENERATE_ASPNET_CERTIFICATE=false
sudo mkdir -p /srv/aspnet/myapp && cd /srv/aspnet/myapp
sudo -E dotnet new web -o src --force
sudo -E dotnet publish src -c Release -o /srv/aspnet/myapp/out --nologo

Start it briefly on port 5001 and confirm it answers:

sudo -E sh -c 'ASPNETCORE_URLS=http://127.0.0.1:5001 dotnet /srv/aspnet/myapp/out/src.dll >/tmp/myapp.log 2>&1 &'
sleep 6
curl -s http://127.0.0.1:5001/ ; echo
sudo pkill -f '/srv/aspnet/myapp/out/src.dll' || true

Scaffolding, compiling and running a brand new ASP.NET Core application with the SDK included in the image

When you are ready to make your application the one served on port 80, publish it over /srv/aspnet/app and restart the service. The unit runs CloudimgAppHost.dll, so either name your assembly to match or edit ExecStart:

sudo dotnet publish /srv/aspnet/myapp/src -c Release -o /srv/aspnet/app
sudo sed -i 's|/srv/aspnet/app/CloudimgAppHost.dll|/srv/aspnet/app/src.dll|' \
  /etc/systemd/system/aspnet-app.service
sudo systemctl daemon-reload && sudo systemctl restart aspnet-app

Keep your application bound to 127.0.0.1 — the unit sets ASPNETCORE_URLS=http://127.0.0.1:5000 and nginx proxies to it. Binding to 0.0.0.0 would expose it past the authenticating proxy.

Step 9 - Check the versions and the licensing

The exact versions the image ships are recorded on disk and reported by the CLI:

dotnet --list-sdks
dotnet --list-runtimes
cat /opt/cloudimg/dotnet-versions.txt

Confirm where the packages came from, and read the licence that ships with the binaries:

apt-cache policy dotnet-sdk-10.0
head -3 /usr/lib/dotnet/LICENSE.txt

The installed SDK and runtimes, the Ubuntu archive origin of the packages, and the MIT licence that ships with the binaries

The full third-party attribution is at /usr/lib/dotnet/ThirdPartyNotices.txt, and the Debian copyright record for every .NET package is under /usr/share/doc/dotnet-*/copyright. A provenance record captured at build time, including the SHA-256 of every package installed, is at /opt/cloudimg/dotnet-licence-provenance.txt.

Step 10 - Change the password

The first-boot password can be replaced at any time. Set a new one for the admin user:

sudo htpasswd -b /etc/nginx/.aspnet_htpasswd admin 'your-new-password-here'
sudo systemctl reload nginx

Then update /root/dotnet-credentials.txt so the machine's record matches what you set.

Maintenance

Your data volume. /srv/aspnet is a separate 20 GiB managed disk. It holds app/ (the running application), src/ (the sample source), keys/ (the Data Protection key ring, which protects cookies and antiforgery tokens) and logs/. Snapshot this disk to back up your application and its keys:

df -h /srv/aspnet
findmnt -no SOURCE,TARGET,FSTYPE,SIZE /srv/aspnet

Do not delete /srv/aspnet/keys on a running system — ASP.NET Core uses it to decrypt cookies and antiforgery tokens issued earlier, and removing it signs every user out.

Logs. The application logs to the journal:

sudo journalctl -u aspnet-app -n 30 --no-pager

Security updates. Unattended upgrades are enabled, and .NET is updated through the normal Ubuntu security channel because it comes from the archive rather than a vendor feed:

apt-cache policy dotnet-runtime-10.0

TLS. The image serves plain HTTP. For production, put your own certificate in front — either terminate TLS on an Azure Application Gateway or Front Door in front of this VM, or install a certificate directly in the nginx server block at /etc/nginx/sites-available/cloudimg-aspnet.

Resizing. The stack is nginx plus a single .NET process and uses well under 300 MiB at rest, so Standard_B2s is comfortable for the sample. Size up when your own application needs it.

Support

cloudimg provides 24/7 support for this image. Contact support@cloudimg.co.uk with your Azure subscription ID and the VM name.

.NET and ASP.NET are trademarks of the Microsoft group of companies. cloudimg is not affiliated with, endorsed by, or sponsored by Microsoft.