Deploying to Azure App Service
A Power Portals Pro portal is a single ASP.NET Core application whichever stack you chose, so deploying it is an ordinary dotnet publish to an ordinary App Service. This page covers the resources to create, the settings the portal reads, what we suggest as defaults, and the handful of steps that are specific to Power Portals Pro rather than to ASP.NET Core.
What you are deploying
Both templates produce one web application and one publish output. There is no separate front-end host to stand up, no static-site resource, and no second App Service:
- Blazor — the server project hosts the app. Under
ServerorAutointeractivity it also serves the Blazor SignalR circuit; underWebAssemblyit serves the client bundle and the/api/*endpoints the client calls. - React —
dotnet publishruns the Vite build for the client project and stages itsdist/into the host'swwwroot, so one ASP.NET Core host serves the SPA and the API from a single origin. Nothing extra to wire up.
Note
Because the React publish shells out to npm, Node has to be installed on whatever machine runs
dotnet publish— your laptop, or the build agent. The template's client asks for Node^20.19.0 || >=22.12.0. App Service itself never needs Node; it only ever sees the built output.
Suggested defaults
Starting points, not rules — but these are what we would pick for a new production portal.
- Operating system: Linux. Cheaper per tier, quicker to start, and it keeps IIS and
web.configout of the picture entirely. Windows works fine if your organisation standardises on it. - Runtime stack: .NET 10. The templates target
net10.0. Publish framework-dependent and let App Service supply the runtime. - Plan: B1 — for every environment, production included. B1 is the cheapest tier that can stay warm, and scaling up later is one command with no redeploy and no meaningful downtime. Provisioning something larger “because it is production” starts a bill nobody revisits; start here and move up when a measurement, or a need for deployment slots, says to. What you give up until then: no deployment slots and no autoscale (both start at Standard — and compare S1 against P0v3, which is usually cheaper on Linux and has more memory).
- Always On: On. App Service unloads an idle app, and a cold start means reconnecting to Dataverse and rebuilding the metadata and localization caches. The first visitor after a quiet spell pays for all of it.
- HTTPS Only: On, minimum TLS 1.2, HTTP/2 enabled. The portal issues authentication cookies; there is no reason to accept plain HTTP.
- Web sockets: On for Blazor Server or Auto. Off is the App Service default, and without it the circuit quietly falls back to long polling.
- Session affinity: On for Blazor Server or Auto — a circuit belongs to one instance. Turn it off for React and Blazor WebAssembly portals: they are stateless over HTTP, and affinity only unbalances your instances.
- Secrets in Key Vault, referenced from app settings. Give the App Service a managed identity and point the setting at the vault, so the secret never sits in the configuration blade or in a deployment script.
- Data Protection keys in Blob Storage as soon as you use deployment slots or scale past one instance. The default key ring is per-slot, and a swap signs everybody out.
WEBSITE_RUN_FROM_PACKAGE=1: optional, and worth it. Deployments become atomic and the content directory read-only. The portal only ever reads from its content root, so nothing breaks — uploads buffer through the system temp folder, which stays writable.- Application Insights: on. The framework logs licence rejections and Dataverse failures through
ILogger. Without a sink you are reading the log stream by hand at exactly the moment you would rather not be.
1. Create the App Service
Three commands. Substitute your own names — myportal is used throughout this page.
az group create --name myportal-rg --location eastus
az appservice plan create --name myportal-plan --resource-group myportal-rg --sku B1 --is-linux
az webapp create --name myportal --resource-group myportal-rg --plan myportal-plan --runtime "DOTNETCORE:10.0"
The web app name becomes https://myportal.azurewebsites.net and has to be globally unique. Choose it deliberately: unless you put a custom domain in front, it is the URL your licence key gets bound to.
2. Set the platform options
These are the ones whose defaults are wrong for a portal.
az webapp config set --name myportal --resource-group myportal-rg --always-on true --min-tls-version 1.2 --http20-enabled true --web-sockets-enabled true
az webapp update --name myportal --resource-group myportal-rg --https-only true --client-affinity-enabled true
--web-sockets-enabled true— required for Blazor Server and Auto. Harmless on React and WebAssembly portals, which never open a circuit.--client-affinity-enabled— leave ittruefor Blazor Server and Auto; passfalsefor React and Blazor WebAssembly so requests spread evenly across instances.--always-on true— Basic tier or above. It is not available on Free or Shared plans.
3. Configuration
App Service application settings arrive as environment variables and override appsettings.json, which is exactly what you want: leave the file alone and set the per-environment values here.
Important
User secrets do not travel. Everything you stored with
dotnet user-secretsduring development lives only on your machine. Every one of those keys has to be recreated as an application setting — or a Key Vault reference — or the deployed app will not start.
Use a double underscore for the section separator: D365:ClientId becomes D365__ClientId. The colon form works on Windows App Service but not on Linux, so __ is the form to use everywhere.
Always required
D365__Url— the Dataverse environment URL, e.g.https://yourorg.crm.dynamics.com. Not a secret.D365__ClientId— the client id of the Entra app registration the portal connects to Dataverse as. Not a secret.D365__ClientSecret— that registration's client secret. Secret: use a Key Vault reference.D365__EmailSenderEmailAddress— the from-address the portal sends account-confirmation and password-reset mail from, through Dataverse. The easiest one to forget, because it is not inappsettings.jsonand the app will not start without it.ASPNETCORE_ENVIRONMENT— set it toProduction. This does more than change the error page: on a React portal the SPA fallback is registered only outside Development, so a site left onDevelopmentredirects deep links tohttp://localhost:5173instead of serving the app.
Required if you enabled that sign-in provider
Authentication__Microsoft__ClientIdandAuthentication__Microsoft__ClientSecret— the sign-in registration, which is a different app registration from the Dataverse one. See Entra ID Sign-In.Authentication__Google__ClientIdandAuthentication__Google__ClientSecret.Authentication__Facebook__AppIdandAuthentication__Facebook__AppSecret.
Optional
Azure__Translation__Key,DeepL__Translation__KeyorGoogle__Translation__Key— whichever machine-translation provider you registered. Leave it unset and the Localization Admin page simply hides its translate panel; the rest of the page still works.PortalIdentity__SystemAdminRoleName— the Dataverse security role that grants portal administrators access. It ships inappsettings.json; override it here when the role name differs per environment.
az webapp config appsettings set --name myportal --resource-group myportal-rg --settings ASPNETCORE_ENVIRONMENT=Production D365__Url="https://yourorg.crm.dynamics.com" D365__ClientId="00000000-0000-0000-0000-000000000000" D365__EmailSenderEmailAddress="portal@contoso.com"
Note
The required keys are read with
GetRequiredValue, so a missing one throws during startup rather than failing later at the first Dataverse call. On App Service that shows up as a site that never comes up — read the log stream, and the exception names the key.
4. Give the portal an address to send from
D365__EmailSenderEmailAddress is what account-confirmation and password-reset mail comes from, and the framework resolves it by looking for a Dataverse user with that address, then a queue, then a team. Use a queue. It is the one option that needs no work in Exchange at all: the mailbox Dataverse creates for a queue is pointed at the organisation’s existing Exchange Online profile, so there is no mailbox to provision and no licence to buy for the address. Pointing the setting at a real person instead costs you three things:
- Their name goes on every automated message. Recipients see a colleague as the sender of a password reset they did not send.
- Replies land in their inbox. People do reply to no-reply mail, and those replies go somewhere nobody is watching for them.
- The portal breaks when they leave. Disabling the account takes the sender with it, and the failure surfaces as account confirmation quietly not working.
Three steps, all inside Dataverse. The MCP server does the first two in one call with create_email_queue:
- Create a private queue with the address (e.g.
noreply@contoso.com), incoming delivery None and outgoing delivery Server-Side Synchronization. The queue’s name is what recipients see as the sender, so name it for the portal rather than for a person. - Approve the address. Two flags have to line up and they live on different records: the queue’s own email-address approval, and the mailbox’s approved by O365 admin. A queue is created pending on both.
- Test & Enable the mailbox under Power Platform admin centre → Settings → Email configuration → Mailboxes. This is the one step that has to be done by hand, and it is a single button.
Important
An unapproved or untested mailbox fails silently. Dataverse accepts the email, records it, and never delivers it — which from the portal’s side is indistinguishable from a successful send. Nothing throws, nothing is logged as an error, and the first sign of trouble is a user saying they never got their confirmation link. If registration emails are not arriving, check the mailbox’s outgoing status before anything else: Not Run means this step was missed.
5. Keep the secrets in Key Vault
Give the app a managed identity, grant it read access to a vault, and put each secret's URI in the app setting instead of the secret itself. App Service resolves the reference for you, and the value never appears in the configuration blade, in a script, or in a deployment log.
az webapp identity assign --name myportal --resource-group myportal-rg --query principalId --output tsv
az keyvault create --name myportal-kv --resource-group myportal-rg --enable-rbac-authorization true
az role assignment create --assignee PRINCIPAL_ID --role "Key Vault Secrets User" --scope VAULT_RESOURCE_ID
az keyvault secret set --vault-name myportal-kv --name D365-ClientSecret --value THE_SECRET
az webapp config appsettings set --name myportal --resource-group myportal-rg --settings D365__ClientSecret="@Microsoft.KeyVault(SecretUri=https://myportal-kv.vault.azure.net/secrets/D365-ClientSecret/)"
When a client secret expires — Entra defaults to a year — add a new version to the vault and restart the app. The app setting itself does not change.
The MCP server does this whole sequence: create_managed_identity makes a user-assigned identity, enable_managed_identity attaches it to the app, create_key_vault creates the vault and grants that identity read access, and the secret tools then accept destination: "key-vault" — which sends a freshly minted client secret from Entra straight into the vault and leaves only the reference in the app settings. The value never becomes a literal setting and never passes through anybody’s hands.
Important
A Key Vault name is reserved for 90 days after deletion. Soft delete is on by default and cannot be turned off, so deleting a vault does not release its name — recreating one with the same name inside that window needs an explicit purge, which itself needs permission and is irreversible. It is the one resource here that is not cheaply undoable, so choose the name as deliberately as you would the App Service’s.
Note
Better still, drop the Dataverse client secret altogether. Set
D365__ManagedIdentityIdto a managed identity’s client id and the portal authenticates to Dataverse as that identity, with no secret and therefore nothing to expire. There is no environment check and no build switch: the setting belongs in the App Service’s configuration rather thanappsettings.json, so it is absent locally and present when deployed, and one build behaves correctly in both. Use a user-assigned identity — a system-assigned one is created and destroyed with the app and differs per deployment slot, so rebuilding the app mints a new identity and orphans the Dataverse application user built on the old one. The identity still needs that application user, created from its application id rather than the principal id the Azure portal shows first.
6. Publish
Publish framework-dependent, zip the output, push the zip. On a React portal the same dotnet publish also builds the SPA and stages it into wwwroot.
dotnet publish ./MyPortal/MyPortal.csproj -c Release -o ./publish
Compress-Archive -Path ./publish/* -DestinationPath ./publish.zip -Force
az webapp deploy --name myportal --resource-group myportal-rg --src-path ./publish.zip --type zip
Zip the contents of the publish folder, not the folder itself — App Service unpacks the archive straight into the site root, so a nested top-level directory produces a site that serves nothing.
Visual Studio's Publish dialog does the same thing interactively, and a GitHub Actions or Azure Pipelines workflow does it on push. The only extra step for a React portal is a Node setup step ahead of the build, since dotnet publish shells out to npm.
7. Point the licence at the deployed URL
The website licence is bound to a URL. In the Power Portals Pro model-driven app, open (or create) the Portal Website record holding your licence key and set its URL to the production URL of the portal you just deployed — https://myportal.azurewebsites.net, or your custom domain if one is going in front of it.
Register the production URL only. Validation also accepts -dev, -test, -uat and -stage suffixed hosts, and the same four as leading subdomains, so lower environments need no separate record. The Licensing page has the full list.
Important
Do this before you send traffic. Every data-serving endpoint checks the licence, so until the record exists and matches the host, the portal renders but every data call comes back
402.
8. Add the production redirect URI
If the portal offers Microsoft sign-in, the sign-in app registration still only knows your localhost redirect URI. Add the deployed one alongside it — Entra accepts several, so the local one keeps working:
https://myportal.azurewebsites.net/signin-microsoft
Same pattern for the other providers on their own callback paths (/signin-google, /signin-facebook). Add one per environment, and one per custom domain.
Deployment slots
A staging slot and a swap give you a warmed-up app and an instant rollback. They are the one thing genuinely worth leaving B1 for — and the reason to leave it when you need them, rather than in advance. Two things need care.
Data Protection keys are per-slot. ASP.NET Core persists the key ring under %HOME%, which each slot has its own copy of, so a swap replaces it — and every authentication cookie issued by the previous slot becomes undecryptable. Everyone is signed out. Move the key ring somewhere both slots share before you rely on swapping:
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(new Uri(blobUri), new DefaultAzureCredential())
.ProtectKeysWithAzureKeyVault(new Uri(keyUri), new DefaultAzureCredential());
Mark the environment-specific settings as slot settings so they stay behind during a swap. The Dataverse environment URL is the one that bites: without it, swapping a staging slot that points at a sandbox promotes that sandbox connection into production.
Scaling out
Scaling out is a decision to make when you measure the need for it, not when you provision. One instance of the default tier carries a surprising amount of portal traffic.
- Blazor Server / Auto: keep session affinity on, and budget roughly 250 KB of server memory per concurrent circuit as a starting point before you measure your own. Azure SignalR Service is not required on App Service — it is a tool for very high connection counts or globally distributed users.
- React / Blazor WebAssembly: turn session affinity off and scale horizontally. The requests are stateless, so instances are interchangeable.
- The templates register
AddDistributedMemoryCache(), which is per-instance. On more than one instance, swap it for a real distributed cache so metadata and localization caches are shared rather than rebuilt on each one.
Doing all of this from an AI agent
The Power Portals Pro MCP server can perform every step on this page. Bind the folder to a subscription once, at a terminal — the binding belongs to that folder, so several projects can target different customers at the same time, and nothing machine-wide changes:
ppp-mcp azure login
check_deployment— the one to reach for first. It compares what the project needs against what the App Service has and names what is wrong: a required setting that only ever existed in your user secrets, the environment left on Development, WebSockets off on a Blazor portal, a Portal Website record that does not cover the host. Read-only, so it is safe to run against production.get_deployment_plan— the steps, and every plan tier with its real current price from Azure’s public price list.create_app_service— resource group, plan and web app, with the WebSockets and affinity pair already set for your stack.set_app_service_settings— merges settings rather than replacing them, so it cannot wipe the ones holding your secrets.deploy_to_app_service— builds withdotnet publishand pushes the result.create_dataverse_app_registration,create_signin_app_registrationandadd_app_registration_redirect_uri— the Entra side of steps 6 and 7, using your own signed-in account’s directory role. A created client secret goes straight into user secrets or the App Service setting and is never shown.create_managed_identity,enable_managed_identityandcreate_key_vault— the identity and vault above, with the role assignments made for you and retried while they propagate.create_email_queue— the no-reply sender queue from step 4, created and approved in one call.
Note
Every tool that changes anything in Azure asks you to confirm first, and tells you what it will cost before you answer — the real monthly figure for your region, with a note that retail list ignores any Enterprise Agreement or reservation you hold. Tools that only read never prompt. The server does not use the Azure CLI and never reads or changes its signed-in state.
Troubleshooting
- The site never comes up. Usually a missing required app setting. The startup exception names the key — read it in Log stream or Diagnose and solve problems.
- The portal renders but every data call returns 402. The licence check is rejecting the host. Confirm the Portal Website record's URL matches the host the browser is actually using, and that the managed solution is installed in the environment the portal connects to. The rejection is logged with the exact host, forwarded host and scheme that were validated.
- Redirect loops, or an
http://URL where you expectedhttps://. The app is not honouring the forwarded headers from the App Service front end. SetASPNETCORE_FORWARDEDHEADERS_ENABLED=trueso the request scheme and client IP reflect the original request. - “Failed to connect via WebSockets, using the Long Polling fallback” in the browser console on a Blazor portal — Web sockets is still Off on the App Service.
- Everyone is signed out after a deployment or a swap. The Data Protection key ring changed; see Deployment slots above.
- A React deep link 404s or redirects to localhost.
ASPNETCORE_ENVIRONMENTis notProduction— the SPA fallback is registered only outside Development. - Nothing useful in the logs. App Service application logging is off by default: turn on filesystem logging at information level, then tail it with
az webapp log tail.
