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: B3 — for every environment, production included. B3 is the top of Basic: Always On, four cores and 7 GB, so a portal starts with headroom rather than meeting its limits under its first real load. Changing tier later is one command with no redeploy and no meaningful downtime — down to B1 for a dev or test environment that never needs the room. What Basic does not give you: deployment slots and 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 B3 --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.Security__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. Its address needs a shared mailbox in Exchange Online, which takes no licence, and everything else happens in Dataverse. 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.
Four steps: one in Exchange, then three in Dataverse. The MCP server does the three Dataverse steps in one call with create_email_queue, and check_email_sender says which step is missing when mail does not arrive:
- Create a shared mailbox with the address, in the Exchange admin centre (Recipients → Mailboxes → Add a shared mailbox). It needs no licence. It is what server-side synchronisation sends through, so without it the last step fails with an error about validating the mailbox configuration in the remote server.
- 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, and only an Office 365 Global Administrator or Exchange Administrator may approve the second.
- Test & Enable the mailbox under Power Platform admin centre → Settings → Email configuration → Mailboxes. It sends a test message from the address to itself, and the mailbox only sends once it has succeeded.
create_email_queueruns it and reports the result.
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 the test never ran, and Failure with an alert about validating the mailbox configuration in the remote server means the address has no shared mailbox in Exchange Online.
check_email_senderreports both, with the mailbox’s alerts.
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. Secrets already in your user secrets go with push_secrets_to_key_vault, which asks you to tick each one before it is copied and points the matching app settings at them; check_deployment then confirms every reference’s secret exists and that the app’s identity may read it.
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. This applies only when the App Service and the Dataverse environment are in the same Entra tenant; if they are not, see When Dataverse is in another tenant below.
When Dataverse is in another tenant
Everything above assumes the App Service and the Dataverse environment belong to the same Entra tenant — the usual case when an organisation hosts its own portal. If you run the portal and your customer owns the Dataverse environment, they are in different tenants, and the identity advice changes.
Important
A managed identity cannot authenticate to Dataverse in another tenant on its own. Its service principal belongs to the subscription’s own tenant and cannot be consented into anybody else’s, so there is nothing for the customer’s Dataverse to create an application user against. Do not set
D365__ManagedIdentityIdby itself in this shape — the portal would get a token and then be refused by every call, which reads like a broken security role rather than the wrong kind of identity. It can, though, be the credential of a multi-tenant app registration, which is what crosses tenants: see the first option below.
The portal signs in as an app registration that the customer’s tenant has a service principal for. There are three ways to arrange that, and they differ in who controls the credential’s lifetime:
- A multi-tenant registration you own — preferred. Create it in your own tenant with
signInAudienceset toAzureADMultipleOrgs, Microsoft Graph’s delegatedUser.Readpermission and a reply URL — admin consent refuses a registration without both, though the portal uses neither (create_dataverse_app_registrationadds them). It never leaves your tenant, and you decide when its secret rotates and when it expires — which is the difference that matters, because a lapsed secret takes the portal down and this is the option where the person who can prevent that is the person who gets called. Better still, give it no secret at all. A federated credential lets the registration accept a user-assigned managed identity of yours in place of one (add_app_registration_federated_credentialadds it); attach the identity to the App Service and setD365__ClientIdto the registration,D365__TenantIdto the customer’s tenant andD365__ManagedIdentityIdto the identity’s client id. The portal proves it is the app with the identity’s token, and nothing expires. - A registration the customer creates in their own tenant. Some organisations require this, and it is supported. The cost is that the credential’s lifetime is theirs: when the secret expires the portal stops, and you cannot renew it. Agree who watches the expiry date before you choose this.
- Power Portals Pro’s own registration — when Power Portals Pro hosts the portal. The customer creates no registration: their administrator consents to Power Portals Pro’s, client id
46c0b100-f468-4bfc-ba89-d128bceabe31, and their Dataverse gets an application user for it. Power Portals Pro’s hosting signs in as it with a managed identity, so there is no secret to store, rotate or expire. It is not an option for a portal you run yourself, because only Power Portals Pro’s own hosting can sign in as it. Its consent link ishttps://login.microsoftonline.com/organizations/adminconsent?client_id=46c0b100-f468-4bfc-ba89-d128bceabe31&redirect_uri=https%3A%2F%2Fpowerportalspro.com%2F.
Whichever you choose, an administrator in the customer’s tenant has to consent to the registration once. That is what creates the service principal there — an object carrying your client id, which their Dataverse can then bind an application user to. Send them this link:
https://login.microsoftonline.com/CUSTOMER_TENANT_ID/adminconsent?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REPLY_URL
Important
Consent comes before the application user, not after. Until it has happened there is no service principal in the customer’s tenant, so creating the Dataverse application user fails with an error about an unknown application — which mentions neither tenants nor consent, and is the single most confusing point on this path. You cannot perform this step for them: it is their tenant granting access to a registration they do not own, so it needs their authority rather than yours.
Put that secret in Key Vault rather than in an application setting. It is a credential against somebody else’s data, and a literal setting is readable by anyone with Contributor on the app and travels with any export of the configuration. check_deployment reports it if you do not.
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, -stage and -prod suffixed hosts, and the same five as leading subdomains, so other 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 Basic 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, a portal that cannot reach Dataverse. 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.get_deployment_planalso states whether this deployment spans two tenants, andcreate_dataverse_app_registrationtakesmultiTenant: truefor the hosted shape — creating the registration, printing the admin-consent link, and putting the secret straight into the vault.create_email_queue— the no-reply sender queue from step 4, created, approved and tested in one call, once its shared mailbox exists in Exchange Online.check_email_sendersays why mail from it is not arriving.
Note
Every tool that changes anything in Azure shows you what it will do before you are asked to confirm, including what it will cost — 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 comes up, but shows a “Some content is unavailable” notice, labels appear as their raw keys and no data loads. The portal cannot sign in to Dataverse. The log names the cause on one line starting Could not connect to Dataverse — usually an expired or wrong
D365__ClientSecret, or aD365__ClientIdfrom another tenant. While it lasts,/healthanswers503 Unhealthy, so an App Service health check or uptime monitor pointed at it will notice, andcheck_deploymentasks it too. - 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.
