Generate HTML Reports Directly From Bash
AI generated
$_
#!/
Bash · Reporting · Automation
Generate HTML Reports Directly From Bash
reporting without a backend, without a template engine

A cron job that runs overnight has to deliver a readable result by morning. Instead of scrolling through a log file, a Bash report builds a finished HTML page with tables, bars, and status badges directly, ready to send by mail or drop on an internal server, without PHP, Python, or a templating library.

17 min read Heredoc · inline CSS · bars without JS · cron mail delivery Bash 4.x · 5.x · Linux · macOS

1. Why Bash is enough for HTML reports

Anyone who runs cron jobs daily, checks backups, or analyzes log files knows the problem: the output ends up in a text file that nobody reads voluntarily. A Bash HTML report solves this without needing a dedicated backend, a database, or a template engine like Jinja or Twig. Bash can assemble HTML strings just like any other text output, and because a browser renders HTML independently of the language that produced it, a Bash HTML report is technically equivalent to one generated by a web application.

The decisive advantage over a full application is the missing overhead. A report that gets sent after a nightly backup run or a database migration needs no web server, no application server, and no deploy pipeline. The script that does the actual work (backup, sync, health check) can generate the Bash HTML report right afterward from the same variables already collected during the run. No extra layer, no extra process, and no extra point of failure is introduced.

This article covers how a Bash HTML report grows from a simple heredoc skeleton into a full dashboard with tables, bar charts, and automatic mail delivery. All examples are production ready and run with pure Bash, with no Python, no Node, and no external templating library.

2. Basic structure: building HTML with a heredoc

The tool of choice for a Bash HTML report is the heredoc. With cat <<HTML > report.html, a multiline string is written directly into a file, and Bash variables inside the heredoc are expanded normally. That distinguishes a plain heredoc from a quoted heredoc (<<'HTML'), where no expansion happens at all, which is useful for static template parts but not an option for a dynamic report.

What matters for every Bash HTML report is a clean separation between static markup and dynamic values. Static parts such as <!DOCTYPE html>, the <head> section, and the base structure get written once inside a heredoc, while dynamic values like timestamps, counts, or status colors are embedded as Bash variables. Anyone who forgets to quote those variables risks word splitting the moment a value contains spaces, for example a hostname with a domain part or a path.


#!/usr/bin/env bash
# generate-report.sh — build a self-contained HTML report with a heredoc
set -euo pipefail

readonly REPORT_FILE="/var/reports/backup-$(date +%Y%m%d).html"
readonly RUN_DATE="$(date '+%Y-%m-%d %H:%M:%S')"
readonly HOSTNAME_LOCAL="$(hostname -f)"

# Values collected earlier in the script (backup routine, health check, ...)
backup_status="OK"
backup_size_mb=482
backup_duration_sec=97

cat <<HTML > "$REPORT_FILE"
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Backup Report — ${HOSTNAME_LOCAL}</title>
</head>
<body>
  <h1>Backup Report</h1>
  <p>Host: ${HOSTNAME_LOCAL}</p>
  <p>Generated: ${RUN_DATE}</p>
  <p>Status: ${backup_status}</p>
  <p>Size: ${backup_size_mb} MB</p>
  <p>Duration: ${backup_duration_sec} seconds</p>
</body>
</html>
HTML

echo "Report written to $REPORT_FILE"

This skeleton already works as the simplest possible Bash HTML report, but it is still far from a usable dashboard. The next step is bringing multiple records into a table instead of listing single values as paragraphs. That is the point where a Bash HTML report starts to offer real value over a plain log file.

3. Collecting data and turning it into table rows

Most reports consist of repeated records: one row per server, per database table, or per failed job. For a Bash HTML report, that means building a loop over the data source and appending a <tr> row to a growing string on each iteration. The trick is to not write the rows directly into the output file but to collect them in a variable first, so the header and footer of the table stay cleanly separated.

For the iteration itself, the same rule applies as for every robust Bash script: never use for x in $(command) without quotes if the values might contain spaces. For a Bash HTML report that lists, say, filenames or paths, this matters especially because a broken HTML document is harder to debug than a broken console output.


#!/usr/bin/env bash
set -euo pipefail

# Build table rows from disk usage per directory, safely quoted
rows=""
while IFS= read -r -d '' dir; do
  size_kb="$(du -sk "$dir" 2>/dev/null | cut -f1)"
  size_mb=$(( size_kb / 1024 ))
  status_class="ok"
  (( size_mb > 5000 )) && status_class="warn"

  rows+="<tr class=\"row-${status_class}\"><td>$(basename "$dir")</td><td>${size_mb} MB</td></tr>"
done < <(find /var/www -maxdepth 1 -mindepth 1 -type d -print0)

cat <<HTML > /var/reports/disk-usage.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Disk Usage Report</title></head>
<body>
  <h1>Disk Usage per Directory</h1>
  <table border="1" cellpadding="6">
    <thead><tr><th>Directory</th><th>Size</th></tr></thead>
    <tbody>${rows}</tbody>
  </table>
</body>
</html>
HTML

One detail overlooked in many scripts: the rows variable must be initialized outside the loop, otherwise it only exists after the first iteration, and set -u would fail on the first string append if the loop stays empty. For a reliable Bash HTML report, that is exactly the case addressed separately in section eight, when a data source turns out to be completely empty.

4. Inline CSS for portable, self-contained reports

A Bash HTML report that gets sent by mail or opened as an attachment cannot rely on an external CSS file. Email clients block external stylesheets by default, and a report opened locally without a web server often cannot find a relative CSS file at all. The fix is embedding the complete styling as a <style> block directly in the <head>, so the HTML file works as a single, self-contained document.

For a Bash HTML report, a compact CSS skeleton is enough: a system font, subtle table borders, and status colors for ok, warning, and failure. Those status colors can be set directly from Bash as CSS classes, as already hinted at in the previous example with row-ok and row-warn. That keeps the logic of what counts as critical inside the Bash script instead of hidden in a separate configuration file.

A common pitfall: embedding the CSS itself through a Bash variable containing curly braces, for example .row-warn { background: #fff3cd; }, works fine in a plain Bash file, as long as that string never runs through a template engine such as Magento's CMS directive parser. In a pure HTML report delivered by mail or a static web server, this is not an issue.

5. Drawing bar charts without JavaScript

A Bash HTML report with plain numeric values reads harder than one with visual bars. The obvious reflex, pulling in a chart library like Chart.js, often hits the same restrictions as CSS: no external script in emails, no CDN access behind firewalls, no extra dependency in a script that otherwise runs completely self-contained. The alternative is simpler than it sounds: a bar is nothing more than a <div> with a percentage width, calculated directly in Bash with integer arithmetic.

The calculation itself uses arithmetic expansion $(( value * 100 / maximum )), which computes in integers in Bash. For a Bash HTML report that shows, say, CPU load, memory usage, or error rates across multiple servers, this simple percentage math is entirely sufficient as long as no decimal precision is required. Anyone who needs real floating point numbers falls back on awk or bc without changing the basic structure of the report.


#!/usr/bin/env bash
set -euo pipefail

# Render a CSS-only bar chart from a list of "label:value" pairs
declare -A metrics=(
  [web-01]=42
  [web-02]=78
  [web-03]=91
  [db-01]=35
)

bar_rows=""
for label in "${!metrics[@]}"; do
  value="${metrics[$label]}"
  color="#16a34a"
  (( value >= 70 )) && color="#dc2626"
  (( value >= 50 && value < 70 )) && color="#d97706"

  bar_rows+="<div style=\"margin:6px 0;\">"
  bar_rows+="<span style=\"display:inline-block;width:80px;\">${label}</span>"
  bar_rows+="<span style=\"display:inline-block;width:${value}%;max-width:400px;background:${color};height:16px;border-radius:3px;\"></span>"
  bar_rows+="<span style=\"margin-left:8px;\">${value}%</span>"
  bar_rows+="</div>"
done

cat <<HTML > /var/reports/cpu-usage.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>CPU Usage Report</title></head>
<body>
  <h1>CPU Usage per Host</h1>
  <div style="font-family:monospace;">${bar_rows}</div>
</body>
</html>
HTML

This pattern scales surprisingly well. A Bash HTML report with twenty or thirty bars looks identical in every modern browser and in almost every email client, because it relies exclusively on inline styles without external dependencies. For stacked or multicolor bars, the same principle extends with several <span> elements side by side, each with its own computed width.

6. Sending reports automatically via cron

A Bash HTML report that only sits on disk locally rarely gets looked at. The real value comes from sending the report automatically to the responsible team's inbox after every run. mutt with the flag -e "set content_type=text/html" is clearly better suited for this than the classic mail command, because mail expects plain text by default and would display HTML tags raw.

Alternatively, sendmail works directly with manually set MIME headers, which gives more control but also requires more boilerplate. For a Bash HTML report in a cron environment, it matters to place delivery as the last step in the same script that generated the report, so no race condition arises between report generation and delivery.


#!/usr/bin/env bash
set -euo pipefail

readonly REPORT_FILE="/var/reports/nightly-$(date +%Y%m%d).html"
readonly RECIPIENT="ops-team@example.com"
readonly SUBJECT="Nightly Report — $(date +%Y-%m-%d)"

# ... report generation happens above this line ...

if command -v mutt >/dev/null 2>&1; then
  mutt -e "set content_type=text/html" \
       -s "$SUBJECT" \
       "$RECIPIENT" < "$REPORT_FILE"
else
  # Fallback: build MIME headers manually for sendmail
  {
    echo "To: $RECIPIENT"
    echo "Subject: $SUBJECT"
    echo "MIME-Version: 1.0"
    echo "Content-Type: text/html; charset=UTF-8"
    echo
    cat "$REPORT_FILE"
  } | sendmail -t
fi

echo "Report sent to $RECIPIENT"

In the crontab itself, a single entry is enough: 0 6 * * * /opt/scripts/generate-report.sh >> /var/log/report.log 2>&1. Because delivery is part of the same script, there is no second cron job waiting for the file to appear or estimating a time delay. A Bash HTML report with built-in delivery is therefore both simpler to operate and less error prone than a multi-stage pipeline.

7. Reusable report sections as functions

Once a Bash HTML report contains more than a handful of sections, for example backup status, disk usage, and an error list in a single dashboard, a function per section pays off. Each function returns an HTML string via echo, the caller captures it in a variable, and all sections are combined at the end in a final heredoc. This separates the data logic of each section from the overall structure of the report.

This pattern also makes a Bash HTML report testable: each section function can be called in isolation with sample data, and the HTML output can be checked manually without regenerating the entire report. For teams maintaining several reports with a similar layout, a shared lib/report-helpers.sh sourced with source also makes sense, providing functions like render_status_badge or render_table for all reports.


#!/usr/bin/env bash
set -euo pipefail

render_status_badge() {
  local status="$1"
  local color="#6b7280"
  case "$status" in
    ok)   color="#16a34a" ;;
    warn) color="#d97706" ;;
    fail) color="#dc2626" ;;
  esac
  echo "<span style=\"background:${color};color:#fff;padding:2px 8px;border-radius:4px;\">${status^^}</span>"
}

render_backup_section() {
  local status="$1" size_mb="$2"
  cat <<SECTION
<h3>Backup</h3>
<p>Status: $(render_status_badge "$status")</p>
<p>Size: ${size_mb} MB</p>
SECTION
}

render_disk_section() {
  local usage_percent="$1"
  local status="ok"
  (( usage_percent > 85 )) && status="fail"
  cat <<SECTION
<h3>Disk Usage</h3>
<p>Status: $(render_status_badge "$status")</p>
<p>Used: ${usage_percent}%</p>
SECTION
}

backup_html="$(render_backup_section "ok" 482)"
disk_html="$(render_disk_section 91)"

cat <<HTML > /var/reports/combined.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Combined Report</title></head>
<body>${backup_html}${disk_html}</body>
</html>
HTML

8. Handling empty data and error cases in the report

A Bash HTML report that silently shows an empty table when the actual data source failed is more dangerous than no report at all. If, for example, find returns no hits because a directory is not mounted, an empty table looks identical to a viewer as a table correctly reporting "no problems found". The report must therefore explicitly distinguish between "no data because everything is fine" and "no data because the collection step failed".

In practice, that means checking the exit code of every data collection step before the corresponding section of the Bash HTML report is rendered. If a collection step fails, a clearly visible error block with a red border is inserted instead of the empty table, pointing out the problem. This prevents a report from falsely giving a green light for days while a data source has already failed in the background.


#!/usr/bin/env bash
set -euo pipefail

collect_disk_data() {
  local dir="$1"
  du -sk "$dir" 2>/dev/null
}

section_html=""
if data="$(collect_disk_data /mnt/backup)"; then
  size_kb="$(echo "$data" | cut -f1)"
  section_html="<p>Backup volume size: $(( size_kb / 1024 )) MB</p>"
else
  # Collection failed — surface a visible error instead of an empty table
  section_html='<div style="border:2px solid #dc2626;padding:12px;background:#fef2f2;">
    <strong>ERROR:</strong> could not read /mnt/backup — is the volume mounted?
  </div>'
fi

echo "$section_html"

Another typical mistake is forgetting HTML escaping for values coming from external sources, for example filenames or log lines. If a filename happens to contain a < or &, it can break the structure of the report. A simple sed substitution of &, <, and > before inserting into the Bash HTML report reliably prevents this problem.

9. Bash reports compared to other approaches

A Bash HTML report is not the right choice for every use case. For interactive dashboards with filtering or live updates, a real web application is superior. For simple, recurring status messages from cron jobs, backup routines, or maintenance scripts, Bash is often the most pragmatic solution, because no additional runtime environment needs to be installed.

Requirement Bash HTML report Python/Jinja Web dashboard
Extra runtime required No Python interpreter Server, DB, frontend
Runs directly from cron Native Possible with venv Not without deploy
Interactivity, filtering Not intended Limited Fully supported
Mail attachment / standalone file Optimal Possible Requires export
Setup effort Minimal Medium High

The table shows the core tradeoff: a Bash HTML report wins on simplicity and portability but loses on interactivity. For the vast majority of operational reports sent once a day or after an event, the simplicity advantage clearly wins out, because nobody needs to maintain or secure an additional application.

Mironsoft

Shell automation, reporting, and deployment infrastructure

Reports nobody has to assemble by hand anymore?

We build automated Bash reports for backup status, deployments, and server health, fully integrated into your existing cron jobs and deployment scripts, with no additional infrastructure.

Report design

HTML reports with tables, bars, and status colors, generated directly from your scripts

Automated delivery

Integration into cron with mutt or sendmail, including failure detection

Maintainable structure

Reusable functions and libraries instead of monolithic scripts

10. Summary

A Bash HTML report solves a very concrete problem: raw log output from cron jobs and maintenance scripts is something nobody reads voluntarily, while a finished HTML document with tables and status colors gets attention. The basic structure is a heredoc combining static markup with dynamically expanded Bash variables. For repeated records, a loop collects table rows in a variable before inserting them into the final heredoc.

Inline CSS makes the report portable as a standalone file for mail attachments and local viewing without a web server. Bar charts can be implemented without any JavaScript library at all as <div> elements with a percentage width, computed directly in Bash with integer arithmetic. Automated delivery via mutt or sendmail belongs as the last step in the same script that generated the report. Functions per section keep larger reports maintainable, and explicit handling of empty or failed data sources prevents a report from falsely giving a green light.

Bash HTML Reports: the essentials at a glance

Basic structure

A heredoc with expanded variables writes HTML directly into a file, no template engine required.

Portability

Inline CSS instead of external stylesheets, so the report works as a mail attachment and locally.

Charts without JS

Bars as a div with a computed percentage width, no chart library needed.

Automation

Delivery via mutt or sendmail in the same script, including failure detection.

11. FAQ: Bash HTML Reports

1Why an HTML report from Bash instead of Python?
Avoids the context switch and an extra dependency when the data-collecting script is already Bash.
2Why a heredoc instead of echo lines?
Multiline HTML with normal variable expansion in a single block, instead of dozens of individual echo calls.
3Why no external stylesheet?
Email clients usually block external CSS files. Inline CSS in the head makes the file self-sufficient.
4Bar chart without JavaScript?
A div with a percentage width computed in Bash as an inline style, color set per threshold via a conditional.
5Which mail tool for HTML reports?
mutt with set content_type=text/html. mail expects plain text. Alternatively sendmail with manual MIME headers.
6Empty report faking a green light?
Check the exit code of every data collection step, render a visible error block instead of an empty table on failure.
7Escape values from filenames?
Yes, replace &, < and > with sed before insertion, otherwise the HTML structure can break.
8How to keep larger reports maintainable?
One function per section, shared helpers sourced from a dedicated library file.
9Is Bash suitable for interactive dashboards?
No, Bash reports are static. For filtering and live updates, a real web application is the right choice.
10How often should it be generated?
Always after a checkable event: backups, deployments, or daily health checks.