Files
zpan/docs/deploy/azure-functions.md
T
Jasper Van a5c281b26a refactor(api)!: make site routes RESTful (#526)
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
2026-07-27 15:22:28 -04:00

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) and location (eastus).

What the workflow does

  1. Check secrets — fails fast if any required secret is missing.
  2. Resolve release tag — pins to a specific ZPan release.
  3. Set up Node 22 and install dependencies.
  4. Azure login — uses the AZURE_CREDENTIALS service principal.
  5. 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)
  6. Buildpnpm build:azure produces the azure-functions/ publish directory.
  7. Migratepnpm db:migrate applies Drizzle migrations to Turso.
  8. Publishfunc azure functionapp publish <name> uploads the bundle.
  9. Set BETTER_AUTH_SECRET — checks whether the setting already exists; generates and sets it if missing.
  10. 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

  1. Open your function app URL in a browser and complete the ZPan setup wizard.
  2. 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

  1. Generate a secret:

    openssl rand -hex 32
    
  2. 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>
    
  3. 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-runs
    

    Use 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-runs
    

    Use 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.