Deployment guide

.NET 10 Web App host

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 lives on a dedicated data volume mounted at /srv/aspnet, separate from the operating system disk. That includes the published application, its source, the Data Protection key ring and its logs.

Where the .NET binaries come from, and why it matters. .NET is open source under the MIT license, 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. 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 license review and receive security updates through the normal apt channel. Nothing from packages.microsoft.com is installed, and an automated build gate 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 until that first boot completes. There is no window in which the machine is reachable without a credential, and no default password exists in the image.

What you get:

  • .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 and 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 SecureAnyCloud support.

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

Prerequisites

  • An active Azure subscription, an SSH key pair, and a VNet and 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 SecureAnyCloud .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 select Review + create and Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group my-resource-group \
  --name my-dotnet-host \
  --image sac1647283583153: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 sac --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 with this command.

sudo cat /root/dotnet-credentials.txt

The file shows 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.

Confirm the permissions and check that first boot completed.

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

Step 4 - Verify the deployment

Check that 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 point of the architecture, and Step 7 proves it.

The unauthenticated health probe answers immediately.

curl -s http://127.0.0.1/health

Step 5 - Open the application in a browser

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

The ASP.NET Core application renders the page on the server, on that request. The three values at the top are computed live. Reloading the page advances the request counter and moves the clock, which is the simplest proof that the runtime is executing your code rather than serving a static file.

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

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

Step 6 - Query the JSON API

The same facts are available as JSON. Use them for monitoring and to confirm what the machine is running.

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

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. This includes a non-standard port if you are behind a proxy or a tunnel.

nginx forwards $http_host rather than $host 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

Verify this yourself rather than take it on trust. First, confirm that the server refuses an unauthenticated request.

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

You should see 401. Now prove that 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 run a control check against 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 that the services are gated on the first-boot marker. This guarantees that no listener exists before a password does.

grep -h ConditionPathExists /etc/systemd/system/aspnet-app.service /etc/systemd/system/nginx.service.d/10-sac-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 that 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 that 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

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 SacAppHost.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/SacAppHost.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 the application 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/sac/dotnet-versions.txt

Confirm where the packages came from, and read the license that ships with the binaries.

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

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/sac/dotnet-licence-provenance.txt.

Step 10 - Change the password

You can replace the first-boot password at any time. Set a new password 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/sac-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

SecureAnyCloud provides 24/7 support for this image. Contact sales@secureanycloud.com with your Azure subscription ID and the VM name.

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

Deploy on Azure

Launch SecureAnyCloud .NET Web App Host on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure