- Add server/services/licensing-refresh-runner.ts: shared runner with 5-min dedup guard, structured INFO logs, and no-op for unbound state - Add workers/scheduled.ts + export scheduled() in workers/bootstrap.ts for CF Workers cron (every 6 hours) - Add [triggers] crons = ["0 */6 * * *"] to wrangler.toml - Add setInterval refresh on boot in server/entry-node.ts with "licensing.refresh.scheduler.started interval=6h" log - Add POST /api/licensing/refresh-cron?secret=... public endpoint (timing-safe secret comparison) for non-CF platforms - Extract ZPAN_CLOUD_URL_DEFAULT to shared/constants.ts, replacing four duplicated literals - Document REFRESH_CRON_SECRET + scheduler setup in all 5 non-CF deploy guides (vercel, netlify, aws-lambda, azure-functions, cloud-run) Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
6.6 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 master 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 —
npm run build:azureproduces theazure-functions/publish directory. - Migrate —
npm run 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) \
npm run dev:node
Local Azure Functions emulation
npm run 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 call using Azure Logic Apps or a Timer Trigger function that makes an HTTP POST to:
POST https://<your-function-app>.azurewebsites.net/api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>Use a recurrence schedule of
0 */6 * * *(every 6 hours).
If REFRESH_CRON_SECRET is not set, the endpoint returns 401 for all requests.