• Jun 1, 2026
  • 6 min read

How My Website Audit Tool Works: Astro, Express and Parallel Shell Audits

I wanted a lead magnet that gives people something real for their email: type a URL, get a graded report on speed, SEO and security in about 20 seconds. Not a fake “analysis” that only exists to collect addresses, but the same tools I run by hand when someone asks me why their site is slow.

This is the first post in a short series on how that tool is built. This one covers the architecture; the next ones cover the checks and the score, how it handles strangers’ URLs safely, and how raw numbers become plain-language advice.

The pieces

LayerWhat it isWhy
Front endAstro page with a React island (framer-motion) for the audit modalStatic and fast; only the modal needs JavaScript
APIExpress (server.js) serving the built front end and POST /api/auditOne process, one port, same origin — no CORS dance for the main page
Audit engineShell scripts: audit-master.sh plus one script per checkLighthouse, linkchecker, curl and jq are command-line tools already
Enhancementscripts/audit-enhance.jsTurns scores into metrics, platform detection and findings
RuntimeOne Docker container with Chromium, curl, jq, bc and linkcheckerLighthouse needs a real browser; everything else is small
flowchart TB
  Browser["Visitor's browser<br/>Astro page + React modal"]
  subgraph Container["Docker container (make prod)"]
    API["Express server.js<br/>serves dist/ + POST /api/audit"]
    Master["audit-master.sh<br/>orchestrator"]
    subgraph Phases["Parallel phases"]
      LH["lighthouse-audit.sh<br/>Chromium"]
      SEO["seo-audit.sh<br/>robots, sitemap, homepage"]
      SEC["security-audit.sh<br/>headers + HTTPS"]
      LINKS["linkcheck-audit.sh<br/>off by default"]
      LAT["pinghttp-audit.sh<br/>latency"]
    end
    Runs[("audit-runs/job-id/<br/>one folder per audit")]
    Enhance["audit-enhance.js<br/>metrics, platform, findings"]
  end
  Target["The audited website"]

  Browser -->|POST /api/audit| API
  API -->|execFile, no shell| Master
  Master --> LH & SEO & SEC & LINKS & LAT
  LH & SEO & SEC & LINKS & LAT -->|JSON reports| Runs
  LH & SEO & SEC & LINKS & LAT -.->|HTTP requests| Target
  Master -->|master JSON + score| Runs
  API --> Enhance
  Enhance --> Runs
  API -->|result + report link| Browser

The container sits on my homelab behind a Cloudflare Tunnel, so nothing on my network is exposed directly; Cloudflare terminates TLS and forwards to the container’s port.

One request, end to end

sequenceDiagram
  participant B as Browser
  participant A as Express API
  participant M as audit-master.sh
  participant P as Audit phases
  participant E as Enhancement
  B->>A: POST /api/audit (name, email, URL, options)
  A->>A: rate limit, validation, URL safety checks
  A->>M: execFile(audit-master.sh, [url]) with AUDIT_REPORT_ROOT
  M->>P: start Lighthouse, SEO, security, latency (links if asked) in parallel
  P-->>M: JSON files in the job folder
  M->>M: weighted overall score → master-*.json
  M-->>A: exit 0
  A->>E: Lighthouse metrics, platform, trust/SEO findings, diagnosis
  E-->>A: enriched result
  A->>A: create report token (24 h)
  A-->>B: JSON result + /api/reports/<token>/master

The API hands off to a shell script — without a shell

The Express handler validates the input and then runs the orchestrator with execFile, not exec:

const jobId = randomUUID();
const reportsRoot = path.join(__dirname, 'audit-runs', jobId);
fs.mkdirSync(reportsRoot, { recursive: true });

const env = {
    ...process.env,
    AUDIT_REPORT_ROOT: reportsRoot,
    SKIP_LH: options?.skipLighthouse ? 'true' : 'false',
    SKIP_LINKS: options?.skipLinks !== false ? 'true' : 'false',
    // ...
};

execFile(command, args, { env, timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, ...);

execFile passes the URL as an argument, never through a shell, so a URL containing ; rm -rf is just a strange URL. Each audit gets its own folder under audit-runs/<job-id>/, which means two audits can never read each other’s files, and cleaning up is deleting a directory.

The phases run in parallel

The orchestrator starts every enabled phase in the background and waits for all of them:

if [ "$SKIP_LH" != "true" ]; then
    ( AUDIT_TIMESTAMP="$TIMESTAMP" ./lighthouse-audit.sh "$URL" > /dev/null 2>&1 ) &
    LH_PID=$!
fi
# ... same for seo, security, links, latency ...

set +e
wait_for_phase "$LH_PID" "Lighthouse"
wait_for_phase "$SEO_PID" "SEO audit"
# ...
set -e

The phases don’t share state — each writes into its own sub-folder — so wall time is roughly the slowest phase (Lighthouse) rather than the sum. A failing phase prints a warning instead of killing the whole audit; its score simply stays at zero. All phases share one timestamp so the files of a run line up.

Once everything has finished, the master script reads each phase’s JSON with jq, computes a weighted overall grade and writes master-<timestamp>.json. How that score is built is the subject of the next post.

Enhancement: from scores to something a human reads

A bare “72/100” doesn’t help anyone. After the shell work, the API reads the Lighthouse JSON for real metrics (LCP, CLS, JavaScript weight), fetches the page once more to detect the platform and look for trust signals, and builds a one-line diagnosis plus a “top issues” list. That layer gets its own post later in the series.

The response to the browser contains the score, the breakdown, the diagnosis and a report URL like /api/reports/<token>/master. The token is a random UUID kept in memory for 24 hours; it maps to the job folder on disk. Nobody can enumerate reports, and old ones stop working on their own.

The container

The image is node:20-slim plus the tools the scripts call:

RUN apt-get update && apt-get install -y \
    curl jq bc python3 python3-pip chromium make \
    --no-install-recommends
RUN pip3 install --no-cache-dir linkchecker || pip3 install --no-cache-dir linkchecker --break-system-packages
ENV CHROME_BIN=/usr/bin/chromium

In production the container runs make prod, which installs dependencies, builds the Astro site and starts node server.js. Express then serves the built dist/ folder (with long cache headers for hashed assets and no-cache for HTML) and the API from the same port.

Leads from the form are captured separately and only with the visitor’s consent; the audit itself works the same either way.

What I’d keep doing

  • Use command-line tools where they’re already the best tool. Lighthouse, linkchecker and curl don’t need wrapping in libraries; a thin shell layer and JSON files are enough.
  • One folder per job. Isolation and cleanup for free.
  • execFile, always. User input never touches a shell.
  • Run independent checks in parallel and let a single failure degrade one category, not the whole result.

You can try the tool for free at webaudit.jalcocertech.com.

Next: what the audit checks and how the score is computed.