powerbi-pdf-export
v1.0.0
Published
Renders a Power BI report (including uncertified custom visuals) in headless Chrome and emails it as a PDF.
Readme
Power BI → PDF by email (works with uncertified custom visuals)
Power BI's own export (Export → PDF, subscriptions, Power Automate, REST ExportToFile) replaces uncertified custom
visuals with "This visual does not support exporting". This job avoids that export entirely: it opens the report in
headless Chrome through Power BI Embedded, prints each report page to PDF exactly as it appears on screen, merges the
pages into one file and sends it to a Power Automate flow, which emails it as an attachment. It runs on a schedule
in Azure, so no laptop needs to be on.
Schedule (Azure Container Apps Job, cron)
→ sign in with the job's managed identity (no stored secret)
→ Power BI REST: report details + embed token (Fabric capacity)
→ headless Chrome + Power BI JS SDK: render each page → PDF
→ merge pages → POST to the Power Automate HTTP trigger → flow emails it from its owner's OutlookSecurity model
| Concern | How it's handled |
|---|---|
| Credentials | Managed identity in Azure: no client secret or certificate to store, rotate or leak. Locally, az login. |
| Power BI access | The identity is Viewer on this one workspace only. Embed tokens are generated per run, for this report and model only. |
| Email permission | The job has no mail or Microsoft Graph permission. It can only call the flow, whose HTTP trigger requires an Entra ID token from your tenant; the flow sends from its owner's Outlook. |
| Wrong recipients | MAIL_ALLOWED_DOMAINS: the job validates every address and stops before rendering if any is outside your domains. |
| Data at rest | The PDF exists only in memory, in the flow's run history (per your environment's retention) and in the email. LOCAL_OUTPUT_DIR (testing) is refused when NODE_ENV=production, which the Docker image sets. Chrome runs in an incognito context with crash dumps and downloads off. |
| Container | Runs as the image's non-root user; the job exits after each run. |
| Config | No secrets in environment variables or .env (unless you use a sig= trigger URL; see the flow section); .env and outputs are git- and docker-ignored. |
1. One-time setup (IT / Power BI admin)
- Deploy the job first (section 4) with a system-assigned managed identity; note its Object (principal) ID and Application (client) ID (Entra ID → Enterprise applications → filter "Managed identities").
- Power BI / Fabric admin portal → Tenant settings → Developer settings
- Service principals can use Fabric APIs → enabled for a security group; add the managed identity to that group.
- Embed content in apps → enabled for the same group (or the organisation).
- Allow visuals created using the Power BI SDK must already be on.
- Workspace access: the report's workspace (Fabric capacity) → Manage access → add the managed identity as Viewer.
- Email: build the Power Automate flow (see Power Automate flow) and set
FLOW_URL. - If the semantic model uses row-level security, set
PBI_RLS_USERNAME/PBI_RLS_ROLESso the embed token sees the right rows.
Managed identities are service principals, so they work with Power BI's service-principal settings above. If your tenant's policy doesn't allow a managed identity in Power BI, use an app registration with a certificate (not a secret) stored in Azure Key Vault, and set
AZURE_TENANT_ID,AZURE_CLIENT_IDandAZURE_CLIENT_CERTIFICATE_PATH. The code needs no change; it uses whichever credential is available.
2. Prepare the report
- Make one or more print pages that show everything you want in the PDF. The PDF captures each page exactly as it renders, without scrolling.
- For long tables, turn on the visual's Print / PDF pages → Split into pages and duplicate the page once per
"Page x of y", setting Page to show 1, 2, 3… on each copy. List those pages in
PBI_PAGES.
3. Test locally (optional, recommended)
You need Viewer access to the workspace yourself.
cd pdf-export
npm install
npx playwright install chromium
az login # your own account; no secrets needed
cp .env.example .env # fill in the Power BI IDs and pages
# render only, no email: SKIP_EMAIL=true and LOCAL_OUTPUT_DIR=./out
npm run export:localCheck the PDF in ./out, then delete it. If a custom visual is cut off or blank, raise RENDER_SETTLE_MS
(e.g. 10000). To test the email, set FLOW_URL and MAIL_TO and leave SKIP_EMAIL unset: the flow is called
with your own az login token, so its trigger must allow your account (e.g. Any user in my tenant).
4. Deploy to Azure (runs with your laptop off)
Requires the Azure CLI (az login) and an Azure subscription. Replace the angle-bracket values.
RG=rg-pbi-pdf-export
LOC=australiaeast
ACR=<uniqueregistryname>
ENV=pbi-pdf-env
JOB=pbi-pdf-export
az group create -n $RG -l $LOC
az acr create -g $RG -n $ACR --sku Basic
az acr build -r $ACR -t pbi-pdf-export:1 . # builds the Dockerfile in this folder, in Azure
az containerapp env create -g $RG -n $ENV -l $LOC
az containerapp job create -g $RG -n $JOB --environment $ENV \
--mi-system-assigned \
--registry-server $ACR.azurecr.io --registry-identity system \
--trigger-type Schedule --cron-expression "0 20,21 * * *" \
--replica-timeout 1800 --replica-retry-limit 1 --parallelism 1 --replica-completion-count 1 \
--image $ACR.azurecr.io/pbi-pdf-export:1 \
--cpu 2 --memory 4Gi \
--env-vars PBI_WORKSPACE_ID=<WORKSPACE_ID> PBI_REPORT_ID=<REPORT_ID> "PBI_PAGES=<Page 1,Page 2>" \
"FLOW_URL=<flow trigger URL>" "MAIL_TO=<team@yourdomain>" MAIL_ALLOWED_DOMAINS=<yourdomain> \
"MAIL_SUBJECT=Sales Performance" "FILE_NAME_PREFIX=Sales Performance" \
REPORT_TIME_ZONE=Australia/Sydney RENDER_SETTLE_MS=8000 RUN_ONLY_AT_HOUR=7
# Show the managed identity's IDs for section 1:
az containerapp job show -g $RG -n $JOB --query identity
# After section 1 is done, run once to test, then check Job → Execution history → Logs.
# Outside 7:xx Sydney time, lift RUN_ONLY_AT_HOUR for the test and put it back afterwards:
az containerapp job update -g $RG -n $JOB --remove-env-vars RUN_ONLY_AT_HOUR
az containerapp job start -g $RG -n $JOB
az containerapp job update -g $RG -n $JOB --set-env-vars RUN_ONLY_AT_HOUR=7The registry uses the job's managed identity (--registry-identity system), so there's no registry password either.
Creating the job assigns that identity AcrPull on the registry, which needs Owner or User Access Administrator; with
only Contributor, ask an admin to run:
az role assignment create --assignee <MANAGED_IDENTITY_OBJECT_ID> --role AcrPull --scope $(az acr show -n $ACR --query id -o tsv)
Schedule times are UTC, so 7:00 AM Sydney is 20:00 UTC during daylight saving (AEDT) and 21:00 UTC otherwise
(AEST). The job is triggered at both (0 20,21 * * *) and RUN_ONLY_AT_HOUR=7 makes the run that isn't 7:xx in
REPORT_TIME_ZONE exit straight away, so it emails at 7:00 local all year. Schedule it after the semantic model's
refresh finishes.
To update after a code change: az acr build -r $ACR -t pbi-pdf-export:2 . then
az containerapp job update -g $RG -n $JOB --image $ACR.azurecr.io/pbi-pdf-export:2.
Settings
| Variable | Required | Meaning |
|---|---|---|
| PBI_WORKSPACE_ID, PBI_REPORT_ID | yes | From the report URL …/groups/<workspace>/reports/<report> |
| PBI_PAGES | no | Page tab names, comma-separated, in PDF order. Empty = all visible pages |
| PBI_FILTERS | no | JSON array of Power BI report filters |
| PBI_RLS_USERNAME, PBI_RLS_ROLES | no | Only for models with row-level security |
| RENDER_SETTLE_MS | no | Wait after Power BI's "rendered" event (default 6000) |
| RENDER_TIMEOUT_MS | no | Overall step timeout (default 180000) |
| FLOW_URL | yes* | The flow's When an HTTP request is received URL |
| FLOW_TOKEN_SCOPE | no | Token scope for a trigger URL without sig= (default https://service.flow.microsoft.com//.default) |
| MAIL_TO | yes* | Recipients, comma or semicolon separated (passed to the flow) |
| MAIL_CC, MAIL_BCC | no | Extra recipients |
| MAIL_ALLOWED_DOMAINS | recommended | Refuse any recipient outside these domains |
| MAIL_SUBJECT | no | Subject; the AEST date is appended, e.g. Sales Performance - 09/10/2026 |
| MAIL_BODY | no | Plain-text body (HTML is escaped; sent to the flow as <p>…</p>) |
| FILE_NAME_PREFIX, REPORT_TIME_ZONE | no | Attachment name <prefix> YYYY-MM-DD HHmm.pdf in that time zone |
| RUN_ONLY_AT_HOUR | no | Only run when it is this hour (0–23) in REPORT_TIME_ZONE; other starts exit without sending |
| SKIP_EMAIL, LOCAL_OUTPUT_DIR | no | Testing only: render without sending, optionally save locally. The job refuses to start if LOCAL_OUTPUT_DIR is set with NODE_ENV=production |
* Not needed with SKIP_EMAIL=true.
Power Automate flow
The job POSTs the PDF (base64) with the recipients, subject and body; the flow sends it from its owner's Outlook.
- make.powerautomate.com → Create → Instant cloud flow → When an HTTP request is received, signed in as the account that should send the email.
- Trigger settings:
- Who can trigger the flow: Any user in my tenant (or Specific users including the job's identity). The URL
then has no
sig=, and the job signs in to it with its managed identity. Avoid Anyone: that URL carries asig=key that works for whoever holds it, so it would have to be stored as a job secret. - Request Body JSON Schema:
{ "type": "object", "properties": { "to": { "type": "string" }, "cc": { "type": "string" }, "bcc": { "type": "string" }, "subject": { "type": "string" }, "body": { "type": "string" }, "fileName": { "type": "string" }, "fileContent": { "type": "string" } }, "required": ["to", "subject", "fileName", "fileContent"] }
- Who can trigger the flow: Any user in my tenant (or Specific users including the job's identity). The URL
then has no
- Office 365 Outlook – Send an email (V2): To =
to, Subject =subject, Body =body; under advanced parameters CC =cc, BCC =bcc; Attachments → Name =fileName, Content = expressionbase64ToBinary(triggerBody()?['fileContent']). - Response: Status Code
200. With this step the job reports success only once the email is sent; without it the trigger returns202straight away and you have to check the flow's run history. - Save, then copy the trigger's HTTP URL into
FLOW_URL.
Troubleshooting
- Sign-in fails in Azure: the job was created without
--mi-system-assigned, or the identity isn't enabled yet. - 401/403 from Power BI: the identity isn't in the workspace, or isn't in the security group allowed to use Fabric APIs.
GenerateTokenfails: the workspace isn't on Fabric/Premium capacity, or the model needs RLS identities.- 401/403 from the flow: the trigger's Who can trigger doesn't include the job's identity, or the token's
audience is wrong (check
FLOW_TOKEN_SCOPE). - Flow failed: 502 / timeout: the flow didn't reach its Response step within 120 seconds, or the Outlook action failed; check the flow's run history.
- "outside MAIL_ALLOWED_DOMAINS": a recipient address isn't in your allowed domains; nothing was sent.
- Blank or partial visual in the PDF: increase
RENDER_SETTLE_MS. - "Report page … not found":
PBI_PAGESmust match the page tab names exactly (case-insensitive).
