- 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
| Layer | What it is | Why |
|---|---|---|
| Front end | Astro page with a React island (framer-motion) for the audit modal | Static and fast; only the modal needs JavaScript |
| API | Express (server.js) serving the built front end and POST /api/audit | One process, one port, same origin — no CORS dance for the main page |
| Audit engine | Shell scripts: audit-master.sh plus one script per check | Lighthouse, linkchecker, curl and jq are command-line tools already |
| Enhancement | scripts/audit-enhance.js | Turns scores into metrics, platform detection and findings |
| Runtime | One Docker container with Chromium, curl, jq, bc and linkchecker | Lighthouse 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 result and the report link
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.