Step-by-step instructions for cloud and iPhone deployment of the ASP.NET Core 9 MVC platform
GuyStore is an ASP.NET Core 9 MVC application. This guide covers two deployment targets. Deployment 1 publishes the app to the cloud so it is reachable over a secure HTTPS URL. Deployment 2 installs it on an iPhone as a Progressive Web App (PWA) using that same HTTPS URL. The two deployments are sequential: you must complete Deployment 1 before Deployment 2 can work, because iPhone microphone and camera access are blocked by browsers on non-HTTPS origins.
Developer PC ──dotnet publish──► Azure App Service (HTTPS)
│
┌──────────────┼──────────────┐
Desktop Browser iPhone Safari iPhone Home Screen
(PWA install) (standalone, full-screen)
Azure App Service is a fully managed PaaS that runs ASP.NET Core apps with zero infrastructure
configuration. It provides automatic HTTPS via *.azurewebsites.net,
built-in load balancing, and a free-tier plan suitable for demos. The deployment uses the
Azure CLI and a zip-based publish — no Docker, no CI/CD pipeline required.
The F1 plan is free but limited to 60 CPU minutes/day and 1 GB RAM. It is sufficient for demos and testing. Upgrade to B1 (~$13 USD/month) for sustained usage. The F1 plan also does not support custom domains with managed SSL certificates — use B1 or above for a custom domain.
Download and run the installer from https://dot.net/download. Choose the SDK (not the Runtime) for the x64 Windows installer. Verify after installation:
dotnet --version
# Expected: 9.0.x
Download the MSI from https://aka.ms/installazurecliwindows and run it. Verify in a new PowerShell window:
az --version
# Expected: azure-cli 2.59.0 or later
Two settings in appsettings.json must be changed before deploying.
Do not commit credentials to source control — override them via Azure App
Settings (Step 4) instead.
| Setting | Development value | Production value | Reason |
|---|---|---|---|
Demo:BypassWebhookAuth | true | false | Prevents unauthenticated webhook calls in production |
Translation:SimulationMode | true | true until live API key is ready | Avoids hitting the free MyMemory rate limit during testing |
GuyStore defaults to EF Core InMemory ("UseInMemoryDatabase": true
in appsettings.json). No SQL Server provisioning is needed for demos.
Set to false and supply ConnectionStrings__DefaultConnection
as an Azure App Setting to persist data across restarts.
If switching to SQL Server, run dotnet ef database update locally first
to create the schema, then deploy — Azure will run MigrateAsync() on startup.
Run these commands in PowerShell. Replace guystore-app with a
globally unique name — it becomes part of the URL
(https://guystore-app.azurewebsites.net).
# 1. Log in to Azure (opens browser for authentication)
az login
# 2. Create a resource group (one logical container for all resources)
az group create `
--name GuyStoreRG `
--location eastus
# 3. Create an App Service plan
# --sku F1 = free tier | --sku B1 = basic (~$13/month)
az appservice plan create `
--name GuyStorePlan `
--resource-group GuyStoreRG `
--sku B1 `
--is-linux
# 4. Create the Web App (Linux, .NET 9)
az webapp create `
--name guystore-app `
--resource-group GuyStoreRG `
--plan GuyStorePlan `
--runtime "DOTNETCORE:9.0"
The --is-linux flag creates a Linux-based plan, which is faster
to provision and slightly cheaper. ASP.NET Core 9 runs identically on both.
Remove the flag to use Windows hosting if preferred.
Azure App Settings are injected as environment variables at runtime and override
values in appsettings.json. The double-underscore
(__) separator maps to nested JSON keys.
az webapp config appsettings set `
--name guystore-app `
--resource-group GuyStoreRG `
--settings `
"ASPNETCORE_ENVIRONMENT=Production" `
"Demo__BypassWebhookAuth=false" `
"Translation__SimulationMode=true" `
"Kafka__BootstrapServers=localhost:9092" `
"Extensiv__SimulationMode=true" `
"Techship__SimulationMode=true" `
"UseInMemoryDatabase=false" `
"ConnectionStrings__DefaultConnection=YOUR_SQL_CONNECTION_STRING" `
"Fido2__ServerDomain=yourdomain.com" `
"Fido2__Origins=https://yourdomain.com"
To enable the live MyMemory translation API (no key required, 2 000 chars/day free),
set Translation__SimulationMode=false.
For Azure Cognitive Translator (unlimited), also add:
az webapp config appsettings set `
--name guystore-app `
--resource-group GuyStoreRG `
--settings `
"Translation__SimulationMode=false" `
"Translation__AzureKey=YOUR_KEY" `
"Translation__AzureRegion=eastus"
For Passkey / WebAuthn in production, update the Fido2 settings to match your real domain:
az webapp config appsettings set `
--name guystore-app `
--resource-group GuyStoreRG `
--settings `
"Fido2__ServerDomain=yourdomain.com" `
"Fido2__Origins=https://yourdomain.com"
Run from the solution root. The -c Release flag enables compiler
optimisations and trims development tooling. The output is a self-contained folder
ready for deployment.
cd "C:\Guy\Notes\Claud Projects\Guy\GuyStore"
dotnet publish GuyStore.csproj -c Release -o ./publish
# Create a zip archive of the published output
Compress-Archive -Path ./publish/* -DestinationPath ./publish.zip -Force
Run dotnet publish against GuyStore.csproj
directly, not against GuyStore.sln. Publishing the solution would
attempt to publish the test projects, which fail because they are not web apps.
Upload the zip to App Service using the az webapp deploy command.
Azure extracts and runs the app automatically.
az webapp deploy `
--name guystore-app `
--resource-group GuyStoreRG `
--src-path ./publish.zip `
--type zip
Deployment typically completes in 60–120 seconds. Watch the progress in the terminal output.
Right-click GuyStore in Solution Explorer → Publish
→ Azure → Azure App Service (Linux) → select the App Service
created in Step 3 → Publish. This performs build, publish, and zip-deploy
in a single click.
Azure automatically provides a free TLS certificate for the
*.azurewebsites.net subdomain.
The app is immediately reachable at
https://guystore-app.azurewebsites.net — no certificate
purchase or configuration required.
To use your own domain (e.g. https://guystore.example.com):
CNAME record pointing
guystore.example.com →
guystore-app.azurewebsites.net.The free F1 plan does not support custom domains or App Service Managed Certificates. Upgrade the plan before adding a custom domain.
Open a browser and navigate to https://guystore-app.azurewebsites.net.
Verify the following:
| Check | Expected result |
|---|---|
| Home page loads | Dashboard with KPI cards and SignalR connection established |
| Shop page | 30 seeded products displayed correctly (pagination at 12/page) |
| Login | Admin and buyer accounts from DataSeeder work |
| Voice Translate page | Page loads; language dropdowns populated |
| Voice Camera page | Page loads; camera starts after granting permission |
| HTTPS padlock | Green padlock shown in browser address bar |
# Quick smoke test from PowerShell
Invoke-WebRequest -Uri "https://guystore-app.azurewebsites.net" -UseBasicParsing | Select-Object StatusCode
# Expected: StatusCode : 200
GuyStore is deployed to iPhone as a Progressive Web App (PWA). The user installs it from Safari by tapping "Add to Home Screen" — no App Store, no Apple Developer account, and no Xcode required for this path. The app then opens full-screen, with its own icon on the Home Screen, just like a native app.
| Option | Tools required | App Store | Recommended for |
|---|---|---|---|
| PWA via Safari (this guide) | iPhone, Safari, HTTPS URL | No | Internal demos, B2B distribution, fast iteration |
| App Store (.NET MAUI Blazor Hybrid) | Mac, Xcode 15+, Apple Developer Program ($99/yr), .NET MAUI | Yes | Public consumer app, requires Apple review |
| TestFlight (internal beta) | Mac, Xcode 15+, Apple Developer Program ($99/yr) | No (invite only) | Closed beta testing before App Store release |
*.azurewebsites.net) satisfies this requirement.
Apple restricts "Add to Home Screen" PWA installation to Safari only. Chrome, Firefox, Edge, and other browsers on iOS are built on Apple's WebKit engine but do not expose the PWA install prompt. Users must open Safari to install the app.
Create the file GuyStore/wwwroot/manifest.json. This file tells
the browser the app name, icon set, colours, and display mode. Replace the icon paths
once the icons are created in Step 2.
{
"name": "GuyStore",
"short_name": "GuyStore",
"description": "B2B Supply Chain & Fulfilment Platform",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#0f172a",
"theme_color": "#1a56db",
"lang": "en",
"icons": [
{ "src": "/images/icons/icon-76.png", "sizes": "76x76", "type": "image/png" },
{ "src": "/images/icons/icon-120.png", "sizes": "120x120", "type": "image/png" },
{ "src": "/images/icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/images/icons/icon-167.png", "sizes": "167x167", "type": "image/png" },
{ "src": "/images/icons/icon-180.png", "sizes": "180x180", "type": "image/png" },
{ "src": "/images/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/images/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
The display: "standalone" value removes the Safari browser chrome
(address bar, toolbar) when the app is opened from the Home Screen, giving a native-app look.
iOS requires PNG icons at specific sizes. Create the folder
GuyStore/wwwroot/images/icons/ and save each PNG there.
| File name | Size (px) | Used for |
|---|---|---|
icon-76.png | 76 × 76 | iPad non-Retina Home Screen |
icon-120.png | 120 × 120 | iPhone Retina (older models) |
icon-152.png | 152 × 152 | iPad Retina |
icon-167.png | 167 × 167 | iPad Pro |
icon-180.png | 180 × 180 | iPhone 6 Plus / current iPhones |
icon-192.png | 192 × 192 | Android Chrome PWA (bonus compatibility) |
icon-512.png | 512 × 512 | Splash screen / App Store preview |
Design one 512 × 512 PNG with the GuyStore logo/branding. Use a free online tool such as realfavicongenerator.net to auto-generate all required sizes from the master image. Download the ZIP and copy the PNGs into the icons folder.
All icons must be PNG. Safari on iOS ignores SVG apple-touch-icon links.
_Layout.cshtml
Open Views/Shared/_Layout.cshtml and add the following block
inside the <head> element, after the existing
<meta name="viewport"> tag.
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- iOS Safari – standalone mode -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="GuyStore" />
<!-- iOS Touch Icons (Safari reads these when manifest icons are ignored) -->
<link rel="apple-touch-icon" sizes="180x180" href="/images/icons/icon-180.png" />
<link rel="apple-touch-icon" sizes="167x167" href="/images/icons/icon-167.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/images/icons/icon-152.png" />
<link rel="apple-touch-icon" sizes="120x120" href="/images/icons/icon-120.png" />
<!-- Viewport – viewport-fit=cover fills the iPhone notch/Dynamic Island area -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- Theme colour for Safari tab bar -->
<meta name="theme-color" content="#1a56db" />
Safari on iOS 14 and earlier ignores manifest.json icons and reads
only apple-touch-icon link tags. iOS 15.4+ reads the manifest.
Providing both ensures correct icon display on all iOS versions.
A service worker is optional for the basic PWA install, but it enables the "Add to Home
Screen" prompt and can provide an offline splash page. Create a minimal service worker
at wwwroot/sw.js:
// sw.js – minimal service worker for GuyStore PWA
const CACHE = 'guystore-v1';
const OFFLINE_URL = '/offline.html';
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE).then(c => c.addAll([OFFLINE_URL, '/manifest.json']))
);
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(clients.claim());
});
self.addEventListener('fetch', e => {
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(() => caches.match(OFFLINE_URL))
);
}
});
Create a minimal wwwroot/offline.html page that tells the user
they are offline. Then register the service worker at the bottom of
_Layout.cshtml, before </body>:
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.catch(err => console.warn('SW registration failed:', err));
}
</script>
The service worker above uses a network-first strategy for page navigations,
so SignalR WebSocket connections are not intercepted. Do not add
SignalR hub URLs (/dashboardHub) to the cache list — service
workers cannot proxy WebSocket connections.
Rebuild and redeploy using the same commands as Deployment 1, Steps 5–6:
cd "C:\Guy\Notes\Claud Projects\Guy\GuyStore"
dotnet publish GuyStore.csproj -c Release -o ./publish
Compress-Archive -Path ./publish/* -DestinationPath ./publish.zip -Force
az webapp deploy `
--name guystore-app `
--resource-group GuyStoreRG `
--src-path ./publish.zip `
--type zip
After deployment, verify the manifest is reachable in a browser:
https://guystore-app.azurewebsites.net/manifest.json — it should
return the JSON you created in Step 1.
These steps are performed on the iPhone by the end user (or during testing).
https://guystore-app.azurewebsites.net
When launched from the Home Screen (not from a Safari tab), GuyStore fills the entire
screen with no browser chrome. The status bar background uses the
theme-color meta tag value (#1a56db).
Camera and microphone permissions granted to the PWA installed from
guystore-app.azurewebsites.net are stored separately from
Safari's permissions. To reset them: iPhone Settings → Privacy & Security →
Camera / Microphone → find GuyStore → toggle off and on.
If App Store distribution is required (e.g. for public consumer download), the web app must be wrapped in a native iOS shell. The recommended approach for an existing .NET app is .NET MAUI Blazor Hybrid, which keeps the entire stack within the .NET ecosystem.
| Step | Tool | Action |
|---|---|---|
| 1 | Mac + Xcode 15+ | Install Xcode from the Mac App Store and accept the license agreement. |
| 2 | Apple Developer Portal | Enrol in the Apple Developer Program (developer.apple.com). Approval takes up to 48 hours. |
| 3 | .NET 9 SDK on Mac | Install the .NET 9 SDK on the Mac and run dotnet workload install maui-ios. |
| 4 | .NET MAUI | Create a new MAUI Blazor Hybrid project, replace the default Blazor content with the GuyStore Razor views, and configure the iOS platform project bundle ID and signing identity. |
| 5 | Xcode | Open the .xcodeproj generated by MAUI. Set the Team (Apple Developer account)
and Bundle Identifier. Archive the app (Product → Archive). |
| 6 | App Store Connect | Upload the archive via Xcode Organizer → Distribute App → App Store Connect. Submit for review. Review typically takes 1–3 days. |
The WKWebView used by MAUI on iOS does not fully expose the
Web Speech API to JavaScript. Voice Camera and Voice Translate may require replacement
with native MAUI speech APIs (Microsoft.Maui.Media.SpeechToText) if
wrapping for App Store distribution.
The Voice Camera and Voice Translate features depend on browser APIs that have varying levels of support on iOS Safari. The table below reflects behaviour as of iOS 17 / Safari 17.
| Browser API | iOS Safari 14.x | iOS Safari 16.x | iOS Safari 17.x | Chrome / Firefox on iOS |
|---|---|---|---|---|
SpeechRecognitionVoice input (mic → text) |
Partial Requires user gesture, auto-stops on silence |
Partial More reliable, still auto-stops |
Yes Best support; continuous mode limited |
No WebKit engine but API not exposed |
MediaDevices.getUserMediaCamera access |
Yes Requires HTTPS + user permission |
Yes | Yes | Yes Camera works in Chrome/Firefox too |
SpeechSynthesisRead Aloud in Voice Translate |
Partial Requires user gesture to unlock audio |
Yes | Yes | Yes |
| Service Worker Offline / PWA caching |
Yes (iOS 14.5+) | Yes | Yes | Yes |
| PWA "Add to Home Screen" Standalone install |
Yes (Safari only) | Yes (Safari only) | Yes (Safari only) | No |
Safari's implementation of SpeechRecognition does not truly support
continuous = true mode. It stops recording after a short period of
silence (typically 5–8 seconds). The voice-translate.js and
voice-camera.js handle the onend event and
display a "Tap mic again to continue" message. Users should speak promptly after tapping the mic.
iOS Safari does not allow recognition.start() to be called without
a preceding user tap. Never call recognition.start() from
DOMContentLoaded, a timer, or a programmatic trigger — only from
inside a click event handler. Both voice scripts already follow this pattern.
On iOS 14 and some iOS 15 versions, SpeechSynthesis.speak() is silently
ignored unless audio has already been unlocked by a user gesture on the page. The Read Aloud
button in Voice Translate is already a direct user-tap trigger, so this is not an issue in practice.
iOS 16.4+ is the recommended minimum for reliable Voice Feature operation. iOS 17+ is preferred. Users on iOS 14.x should be informed that voice recognition may stop unexpectedly or not be available.
The Voice Camera page only needs the microphone briefly (to catch trigger words) and tolerates auto-stop behaviour well — the user simply speaks again. Voice Translate needs a longer dictation window and benefits most from iOS 17's improved SpeechRecognition stability.
All keys map to appsettings.json and can be overridden by Azure App
Settings using double-underscore notation (e.g. Demo__BypassWebhookAuth).
Never store secrets in appsettings.json committed to source control —
use Azure App Settings or Azure Key Vault for secrets.
| Key | Development default | Production recommendation | Notes |
|---|---|---|---|
ASPNETCORE_ENVIRONMENT |
Development |
Production |
Disables detailed error pages and developer exception pages |
Demo:BypassWebhookAuth |
true |
false |
Set to false to enforce HMAC signature verification on Shopify webhooks |
Translation:SimulationMode |
true |
false (when ready) |
false routes through MyMemory API (no key, 2 000 chars/day free per IP) |
Translation:AzureKey |
(not set) | Set when using Azure Translator | Azure Cognitive Services Translator subscription key |
Translation:AzureRegion |
eastus |
Match your Azure region | Only used when AzureKey is set |
Kafka:BootstrapServers |
localhost:9092 |
Kafka broker address or leave as-is | If unreachable, app falls back to in-process Channel<T> automatically |
Extensiv:SimulationMode |
true |
true until live Extensiv API credentials are available |
Set to false and supply Extensiv:ApiKey to go live |
Techship:SimulationMode |
true |
true until live Techship API credentials are available |
Set to false and supply Techship:ApiKey to go live |
UseInMemoryDatabase |
false |
false |
true = EF Core InMemory (demo/test only, data lost on restart); false = SQL Server (requires ConnectionStrings:DefaultConnection). Always InMemory when ASPNETCORE_ENVIRONMENT=Testing. |
Fido2:ServerDomain |
localhost |
Your production hostname (e.g. yourdomain.com) |
Relying Party (RP) domain for WebAuthn / passkey ceremonies — must match the browser's origin exactly |
Fido2:Origins |
http://localhost:5090,https://localhost:7091 |
https://yourdomain.com |
Comma-separated allowed origins for FIDO2. Add staging URLs if needed. |
az webapp config appsettings set `
--name guystore-app `
--resource-group GuyStoreRG `
--settings `
"ASPNETCORE_ENVIRONMENT=Production" `
"Demo__BypassWebhookAuth=false" `
"Translation__SimulationMode=false" `
"Extensiv__SimulationMode=true" `
"Techship__SimulationMode=true" `
"UseInMemoryDatabase=false" `
"ConnectionStrings__DefaultConnection=YOUR_SQL_CONNECTION_STRING" `
"Fido2__ServerDomain=yourdomain.com" `
"Fido2__Origins=https://yourdomain.com"
# List all current app settings
az webapp config appsettings list `
--name guystore-app `
--resource-group GuyStoreRG `
--output table
Azure App Settings changes take effect immediately after save, but ASP.NET Core caches configuration on startup. Restart the app to pick up changes:
az webapp restart --name guystore-app --resource-group GuyStoreRG