# Azure Functions Deployment ZPan supports deployment to [Azure Functions](https://learn.microsoft.com/en-us/azure/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](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) | | Azure Functions Core Tools | v4 — installed automatically by the workflow | | Turso account | [turso.tech](https://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 ```sh 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`). ```sh # Replace with your Azure subscription ID. az ad sp create-for-rbac \ --name "zpan-deploy" \ --role Contributor \ --scopes /subscriptions/ \ --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 ```json { "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. **Build** — `pnpm build:azure` produces the `azure-functions/` publish directory. 7. **Migrate** — `pnpm db:migrate` applies Drizzle migrations to Turso. 8. **Publish** — `func azure functionapp publish ` 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 ```sh curl https://.azurewebsites.net/api/health # → {"status":"ok"} ``` --- ## Local development against a Turso database ```sh 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 ```sh pnpm build:azure cd azure-functions func start ``` Requires the [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) 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:** ```sh openssl rand -hex 32 ``` 2. **Add the env var** in the Azure portal (**Function App → Configuration → Application settings**) or via CLI: ```sh az functionapp config appsettings set \ --name \ --resource-group \ --settings REFRESH_CRON_SECRET= ``` 3. **Schedule the calls** using [Azure Logic Apps](https://learn.microsoft.com/en-us/azure/logic-apps/) or Timer Trigger functions. Make an HTTP POST to: ``` POST https://.azurewebsites.net/api/licensing/refresh-cron?secret= ``` Use a recurrence schedule of `0 */6 * * *` (every 6 hours). Make another HTTP POST to: ``` POST https://.azurewebsites.net/api/licensing/traffic-sync-runs?secret= ``` Use a recurrence schedule of `*/10 * * * *` (every 10 minutes). If `REFRESH_CRON_SECRET` is not set, the endpoint returns `401` for all requests.