#Deploy to Azure
StoryLark runs on Azure the same way it runs on Cloudflare: one deployment per brand, from the same codebase. This guide provisions a branded site on Azure App Service, PostgreSQL Flexible Server, and Blob Storage.
Full detail (env vars, publishing content, how the Azure path differs
internally) lives in platforms/azure/README.md
— this page is the quick path.
#What gets created
platforms/azure/infra.bicep provisions, per brand:
| Resource | Purpose |
|---|---|
| Azure App Service (Node 20, Always On) | Runs the app + API (platforms/azure/server.mjs) |
| PostgreSQL Flexible Server + database | Accounts, sessions, progress — the database driver |
| Storage Account + public Blob container | Published content — the storage driver |
#Picking a region — check before you deploy, not after
Not every Azure region works for every resource on every subscription.
This isn't about which regions Azure offers in general — it's about what
your specific subscription is allowed to provision, which varies
per-subscription and can differ for each resource type independently. On
the subscription this was built and tested against, eastus turned out to
be blocked for both resources this template needs (Postgres Flexible
Server provisioning restricted; zero App Service B-series VM quota), while
centralus had neither problem. There's no way to know in advance which
region is open for your subscription — you have to check.
Before setting AZURE_LOCATION, run both of these checks:
- Is PostgreSQL Flexible Server provisioning open in this region?
An empty/null result means it's open. Any text back (e.g. "Provisioning is restricted in this region...") means it's blocked for your subscription — try a different region for this check.az rest --method get --url "https://management.azure.com/subscriptions/<sub-id>/providers/Microsoft.DBforPostgreSQL/locations/<region>/capabilities?api-version=2025-08-01" --query "value[0].reason" - Does this region have App Service compute quota for the SKU you want?
Ifaz vm list-usage --location <region> --query "[?contains(localName, 'BS Family')]" -o tableCurrentValueequalsLimit(often0/0), there's no quota here — either pick a different region or use the freeF1tier (APP_SERVICE_SKU=F1ininstall.env— see the note on F1 below).
If the two checks disagree — say Postgres is open in eastus but App
Service quota is only open in centralus — you don't have to pick one
region for everything. AZURE_LOCATION covers App Service + Storage;
DB_LOCATION (defaults to AZURE_LOCATION if unset) covers Postgres
independently, so the database can live in a different region from
everything else. This template does that by design.
#Steps
- Fill the installer's env file:
Setcp platforms/azure/install.env.example platforms/azure/install.envBRAND_ID,AZURE_RESOURCE_GROUP,AZURE_LOCATION,DB_ADMIN_PASSWORD— run the region checks above first. Seeinstall.env.examplefor the optional overrides (DB_LOCATION,APP_SERVICE_SKU,BRAND). - Verify before provisioning anything (creates nothing — checks your
values, that you're logged into Azure, and that the infrastructure
template compiles):
cd platforms/azure && node install.mjs --verify - Deploy (creates real resources and real cost — confirm the plan
with whoever approves cloud spend before running this). This one command
does everything: provisions the infrastructure, applies the database
schema, builds the app for your brand, and deploys the app code to the
App Service.
It prints the live URL when done.node install.mjs --deploy --yes - Publish content through Azure Blob instead of the Cloudflare default:
Seenode packages/pipeline/publish.mjs --brand <your-id> \ --source <path-to-your-content> --storage azure-blobauthoring-stories.mdfor the content format.
#Things that go wrong (found by actually deploying, not just reading the template)
ParameterOutOfRange: 'Version' should be in: []on the PostgreSQL resource, orSubscriptionIsOverQuotaForSkuon the App Service Plan. You skipped (or need to redo) the region checks above — see "Picking a region." Note onF1: the free tier doesn't support Always On (the template already handles this) and has a small daily compute quota that a crash-loop can burn through fast — it's a workaround for zero App Service quota, not the recommended target for anything beyond a quick test.extension "citext" is not allow-listed for usersduring migration. Already fixed ininfra.bicep(anazure.extensionsconfiguration resource allow-lists it) — if you're deploying against an existing server that predates this, allow-list it manually:az postgres flexible-server parameter set --name azure.extensions --value citext.
#Nothing about your brand changes
Switching platforms never touches brands/<id>/ — the same theme,
presentation, and content publish identically whether the site runs on
Cloudflare or Azure. Only the infrastructure underneath differs.
Found a gap? StoryLark is open source — improve these docs on GitHub.