# Share an HTML Report From Airflow

Canonical: https://commareports.com/ci/airflow-html-report
Published: 2026-09-09

> Airflow writes the report to a worker's local disk, or to a bucket only the DAG can read, and the UI is behind a login. Publish it from the task so the analyst waiting on it gets a URL.

# Share an HTML report from Airflow

The DAG builds the report. That part works. The trouble starts one line later,
when the task writes `report.html` and the question becomes who can read it.

- **Worker filesystems are ephemeral.** With the Celery or Kubernetes executor
  the file is on a container that may be gone before you go looking. Even with
  a local executor, "SSH to the scheduler box" is not a distribution strategy.
- **The bucket moves the problem.** S3 or GCS is the standard answer, and it
  turns "who can read this report" into "who has credentials to this bucket" —
  which the analyst waiting on the daily refresh does not, and should not need.
- **The Airflow UI won't render it.** It shows logs and task state, both behind
  an Airflow login. It is not a place to publish a document to a business
  reader.
- **Emailing the file** gets you an attachment that opens as a blank page from
  someone's downloads folder — see [why an HTML file won't open](/fix).

## Publish from the task

One task at the end of the DAG, unconditional, PATCHing a saved report id:

```python
import json, os, urllib.request
from airflow.models import Variable

def publish_report(**_):
    html = open("/tmp/report.html", encoding="utf-8").read()
    report_id = Variable.get("comma_report_id")
    body = json.dumps({"title": "Daily revenue refresh", "html": html}).encode()
    req = urllib.request.Request(
        f"https://commareports.com/api/v1/reports/{report_id}",
        data=body,
        method="PATCH",
        headers={
            "Authorization": f"Bearer {os.environ['COMMA_API_TOKEN']}",
            "Content-Type": "application/json",
        },
    )
    urllib.request.urlopen(req).read()
```

Wire it as the last task with `trigger_rule="all_done"` so it runs when an
upstream task fails too — that is the run people most want to look at. An
`on_failure_callback` publishing a short failure digest works for the same
reason.

Three details make this hold up in practice:

- **`PATCH` a saved id, don't `POST`.** POSTing creates a new report every
  night, which is a spray of orphan links nobody bookmarks. One id gives the
  DAG a permanent URL and a revision per run.
- **Keep the id in an Airflow Variable**, so it survives redeploys and is
  visible to whoever is on support.
- **Scope the token to `reports:write`** and store it in your secrets backend —
  see [API tokens](/docs/api-tokens).

## What the reader gets

- **A URL, and nothing to install.** No Airflow login, no bucket credentials.
- **Faithful rendering** of the Plotly figure or the pandas table the DAG
  generated — see [sharing a pandas DataFrame](/share-pandas-dataframe-html)
  and [sharing a Plotly chart](/share-plotly-html).
- **A comment on the row that's wrong**, anchored there, rather than a Slack
  message you have to reconstruct — see [commenting on HTML](/comment-on-html).
- **Yesterday, still readable.** The revision history is the audit trail for
  "when did this number change".

## Worth knowing

- **Multi-file output** — a directory with `index.html` and assets beside it —
  needs the folder treatment, see [sharing an HTML folder](/share-html-folder).
- **HTML body: 5 MB.** A dataframe dumped whole will exceed that; publish the
  aggregate and attach the raw extract.
- **Rate limit: 60 requests/minute per token.** A DAG that fans out over 200
  partitions should publish once from a summary task, not once per partition.
- **Backfills.** A backfill PATCHing the same id will write one revision per
  backfilled day, in whatever order the scheduler runs them. If that matters,
  publish backfills to a separate report.

## Try it

Comma is free — unlimited reports, unlimited commenters, unlimited revision
history.

**[Create your first report →](https://commareports.com/)**

### Related

- [Publish from CI](/ci) — the general pipeline pattern
- [Share a Dagster asset report](/ci/dagster-html-report) ·
  [Share a Prefect flow report](/ci/prefect-html-report)
- [Share dbt docs](/share-dbt-docs) ·
  [Scheduled HTML reports](/features/routines/scheduled-html-reports)
