Move site configuration under settings, model analytics and licensing as resources, and isolate scheduler runs under the internal API. BREAKING CHANGE: site email, branding, analytics, licensing, WebDAV verification, and scheduler endpoint paths have changed. Refs #451
6.9 KiB
Azure Functions Deployment
ZPan supports deployment to Azure Functions (programming model v4, Node.js 22) as an alternative to Cloudflare Workers or Docker. The function app serves both the Hono API and the React SPA from a single Consumption-plan function.
S3-compatible storage required — Azure Blob Storage is not adapted as an object backend. You must bring an external S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, etc.) and configure it as a storage provider inside ZPan after deployment.
Prerequisites
| Requirement | Notes |
|---|---|
| Azure subscription | Consumption-plan Functions are free up to 1 M invocations/month |
| Azure CLI | az ≥ 2.50 — install |
| Azure Functions Core Tools | v4 — installed automatically by the workflow |
| Turso account | turso.tech — free tier covers most self-hosted use cases |
| S3-compatible bucket | Any provider; configured inside ZPan post-deploy |
1 — Create a Turso database
turso db create zpan
turso db show zpan # note the URL (libsql://...)
turso db tokens create zpan # note the auth token
2 — Create an Azure service principal
The GitHub Actions workflow authenticates to Azure with a service principal whose credentials are stored as a single JSON secret (AZURE_CREDENTIALS).
# Replace <subscription-id> with your Azure subscription ID.
az ad sp create-for-rbac \
--name "zpan-deploy" \
--role Contributor \
--scopes /subscriptions/<subscription-id> \
--sdk-auth
The command outputs a JSON block. Copy the entire JSON object — it is the value for the AZURE_CREDENTIALS secret.
Service-principal JSON format
{
"clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"clientSecret": "your-client-secret",
"subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"activeDirectoryEndpointUrl": "https://login.microsoftonline.com",
"resourceManagerEndpointUrl": "https://management.azure.com/",
"activeDirectoryGraphResourceId": "https://graph.windows.net/",
"sqlManagementEndpointUrl": "https://management.core.windows.net:8443/",
"galleryEndpointUrl": "https://gallery.azure.com/",
"managementEndpointUrl": "https://management.core.windows.net/"
}
3 — Add GitHub repository secrets
In your fork go to Settings → Secrets and variables → Actions and add:
| Secret name | Value |
|---|---|
AZURE_CREDENTIALS |
Full JSON object from az ad sp create-for-rbac --sdk-auth (see above) |
TURSO_DATABASE_URL |
libsql://your-db-name-orgname.turso.io |
TURSO_AUTH_TOKEN |
Token from turso db tokens create zpan |
BETTER_AUTH_SECRET |
(optional) Pre-generated secret — openssl rand -base64 32. If absent the workflow generates one automatically on first deploy. |
4 — Run the workflow
The workflow triggers automatically on every push to main and can be triggered manually via Actions → Deploy to Azure Functions → Run workflow.
The workflow includes
if: github.repository != 'saltbo/zpan'so it is a no-op in the upstream repo. It only runs in your fork.
| Manual dispatch input | Description |
|---|---|
resource_group |
Azure Resource Group name — created automatically if it does not exist (default: zpan-rg) |
location |
Azure region (default: eastus) |
version |
Release tag (e.g. v2.5.0). Leave empty to use the latest release. |
When triggered by a push, defaults are used for
resource_group(zpan-rg) andlocation(eastus).
What the workflow does
- Check secrets — fails fast if any required secret is missing.
- Resolve release tag — pins to a specific ZPan release.
- Set up Node 22 and install dependencies.
- Azure login — uses the
AZURE_CREDENTIALSservice principal. - Provision infrastructure via
deploy/azure-functions/main.bicep:- Resource Group (idempotent
az group create) - Storage Account (required by the Functions runtime)
- Consumption plan (Y1 / Dynamic SKU)
- Function App (Node 22, runtime v4)
- Resource Group (idempotent
- Build —
pnpm build:azureproduces theazure-functions/publish directory. - Migrate —
pnpm db:migrateapplies Drizzle migrations to Turso. - Publish —
func azure functionapp publish <name>uploads the bundle. - Set
BETTER_AUTH_SECRET— checks whether the setting already exists; generates and sets it if missing. - Update
APP_URL— patches the real function-app URL into its own app settings.
Re-running the workflow is safe — Bicep uses create-or-update semantics and the secret step skips if the setting is already present.
5 — Post-deploy: configure S3 storage
- Open your function app URL in a browser and complete the ZPan setup wizard.
- Navigate to Admin → Storages and add your S3-compatible bucket credentials.
Verify the deployment
curl https://<your-func-app>.azurewebsites.net/api/health
# → {"status":"ok"}
Local development against a Turso database
TURSO_DATABASE_URL=libsql://your-db.turso.io \
TURSO_AUTH_TOKEN=your-token \
BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
pnpm dev:node
Local Azure Functions emulation
pnpm build:azure
cd azure-functions
func start
Requires the Azure Functions Core Tools v4 and a local .env file (or environment variables) with TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, and BETTER_AUTH_SECRET.
Entitlement Refresh (License Cert)
ZPan refreshes its entitlement certificate every 6 hours. On Azure Functions there is no persistent process, so you need to trigger a refresh via an external scheduler.
Setup
-
Generate a secret:
openssl rand -hex 32 -
Add the env var in the Azure portal (Function App → Configuration → Application settings) or via CLI:
az functionapp config appsettings set \ --name <your-function-app> \ --resource-group <your-rg> \ --settings REFRESH_CRON_SECRET=<your-secret> -
Schedule the calls using Azure Logic Apps or Timer Trigger functions.
Make an HTTP POST to:
POST https://<your-function-app>.azurewebsites.net/api/internal/licensing/refresh-runsUse a recurrence schedule of
0 */6 * * *(every 6 hours).Make another HTTP POST to:
POST https://<your-function-app>.azurewebsites.net/api/internal/traffic-sync-runsUse a recurrence schedule of
*/10 * * * *(every 10 minutes).
Send Authorization: Bearer <REFRESH_CRON_SECRET> with every scheduler request. If REFRESH_CRON_SECRET is not set, the endpoint returns 401 for all requests.