Skip to main content

🚀 Build & Deploy the Docs Site

This page documents docusaurus-deploy.sh, the script that builds a Docusaurus site inside a Node container and copies the static output to the web server's document root. It's parameterized by a single PJT_NAME variable, so the same script deploys any docs project on the org just by changing that one value.

What the script does
  1. Updates a persistent local clone of the repo (or clones it the first time).
  2. Rewrites url and baseUrl in the Docusaurus config for this deployment.
  3. Runs npm install + npm run build inside a node:lts container.
  4. Mirrors the built build/ output into the web root with rsync.
  5. Regenerates the landing page listing all deployed projects.

Prerequisites​

  • Docker installed and running on the host.
  • git and rsync on the host.
  • Write access to the deployment directory (the script uses sudo).
  • Outbound network access from the container to the npm registry. This host reaches the internet through a resolver passed explicitly as --dns 8.8.8.8; adjust that value if your network requires a different one.

The build runs in the Debian-based node:lts image, which already bundles git, Python 3, and a C/C++ toolchain — so Docusaurus's git lookups and any native npm modules (e.g. better-sqlite3, which compiles with node-gyp) work with no extra setup.

Configuration​

Only PJT_NAME normally changes between projects. Everything else is derived from it, except SITE_URL, which is fixed to the shared domain.

VariableMeaningValue
PJT_NAMEProject / repo name; drives all the paths belowiiot-docs
REPO_URLGit repository to buildhttps://github.com/ruseleredu/$PJT_NAME
SITE_URLPublic domain, no trailing slash (shared)https://docs.mini.pc
BASE_URLSub-path the site is served from, with both slashes/$PJT_NAME/
DESTDirectory the static files are copied to/var/www/html/docs/$PJT_NAME
WORKDIRPersistent working clone on the host/opt/docusaurus-builder/$PJT_NAME
BASE_URL must match the deployed path

baseUrl is the sub-path the browser requests assets from. If it doesn't line up with where the files actually live (both the DEST copy path and your nginx/apache location), the page will load but all CSS and JS will 404 and the site renders unstyled. Because BASE_URL, DEST, and the nginx location are all derived from PJT_NAME, they stay in sync automatically — but if you override any of them by hand, override all of them.

One domain, many projects

SITE_URL stays fixed while everything else keys off PJT_NAME. This assumes every project shares docs.mini.pc and is distinguished by its baseUrl sub-path. If a project ever needs its own domain, move SITE_URL into the per-project variables too.

The script​

docusaurus-deploy.sh
#!/bin/sh
set -e
PJT_NAME="iiot-docs"
REPO_URL="https://github.com/ruseleredu/$PJT_NAME"
SITE_URL="https://docs.mini.pc"
BASE_URL="/$PJT_NAME/"
DEST="/var/www/html/docs/$PJT_NAME"
WORKDIR="/opt/docusaurus-builder/$PJT_NAME"

# Update in place instead of re-cloning
if [ -d "$WORKDIR/.git" ]; then
git -C "$WORKDIR" fetch origin
git -C "$WORKDIR" reset --hard @{u} # discard last run's sed edits, match remote
else
git clone "$REPO_URL" "$WORKDIR"
fi
cd "$WORKDIR"

# Detect config extension, then re-apply edits (reset --hard wiped them)
CONFIG=$(ls docusaurus.config.ts docusaurus.config.js 2>/dev/null | head -n1)
sed -i "s|url:.*|url: '${SITE_URL}',|g" "$CONFIG"
sed -i "s|baseUrl:.*|baseUrl: '${BASE_URL}',|g" "$CONFIG"
grep -nE "^\s*(url|baseUrl):" "$CONFIG" # sanity check before building

# node_modules lives in $WORKDIR and persists; npm install is incremental.
# node:lts (Debian) ships git + Python + toolchain, so native modules compile.
docker run --rm --dns 8.8.8.8 -v "$PWD":/app -w /app node:lts \
sh -c "npm install && npm run build"

sudo mkdir -p "$DEST"
sudo rsync -a --delete build/ "$DEST"/

# Refresh the landing page that lists every deployed project
sudo DOCS_ROOT=/var/www/html/docs SITE_TITLE="docs.mini.pc" \
sh /opt/docusaurus-builder/generate-index.sh
Why node:lts and not node:lts-alpine

The Alpine image is ~300 MB smaller but ships neither git nor a build toolchain. That means installing git for Docusaurus's "last updated" lookups and compiling any native module from source — extra moving parts that caused DNS and node-gyp/Python failures earlier. Debian node:lts bundles all of it, so the run line needs no apk add and no custom image. Docker caches the image after the first pull, so it's downloaded only once.

Deploy​

Place the script somewhere convenient, make it executable once, then run it for every update:

chmod +x docusaurus-deploy.sh # first time only
./docusaurus-deploy.sh

To deploy a different project, change one line:

PJT_NAME="other-project"

The grep line prints the two config values it just set — confirm they read as expected before trusting the build:

22: url: 'https://docs.mini.pc',
25: baseUrl: '/iiot-docs/',

How the config gets rewritten​

The script auto-detects whether the repo uses docusaurus.config.ts or docusaurus.config.js and edits whichever it finds:

CONFIG=$(ls docusaurus.config.ts docusaurus.config.js 2>/dev/null | head -n1)

The two sed lines then replace everything after url: / baseUrl: up to the end of the line. This works because Docusaurus's other URL-ish fields (editUrl, favicon) don't start with a lowercase url:, so they're left alone. Two things to keep in mind:

  • The replacement wipes any trailing comment on those lines.
  • sed here uses | as the delimiter, so a SITE_URL/BASE_URL containing a literal | or & would break it. Not a concern for these values.

If CONFIG ever comes back empty (neither file found), the grep sanity check fails immediately under set -e, stopping the run before a bad build.

The build container​

The build runs in the stock node:lts image with no customization — no docker build, no Dockerfile, no image guard. Because the image is used as-is, Docker pulls it once and caches it; every later run reuses that cached image.

node:lts is Debian-based and built on buildpack-deps, so it already contains everything the build needs: git (for Docusaurus's last-update lookups), Python 3, make, and g++ (for compiling native npm modules). Nothing is installed at run time, which removes the in-container apk add step — and with it the DNS-at-build-time failures the Alpine approach was prone to.

Keeping the image current

node:lts tracks the current LTS line but a locally cached copy won't update on its own. Pull a fresh one when you want the latest patch:

docker pull node:lts

Pin a specific version instead (e.g. node:22-bookworm) if you need reproducible builds across machines.

Speed: why updates are fast​

The script is built to avoid redoing work on every run.

  • Persistent clone. After the first run, git fetch + git reset --hard pull only new commits instead of re-downloading the whole repo.
  • npm install, not npm ci. npm ci deletes node_modules and reinstalls all ~1,460 packages every time (~33 s). Because the clone persists, node_modules survives, and npm install against an unchanged lockfile is nearly a no-op. It still reconciles correctly when the lockfile changes upstream.
  • Cached image, no per-run setup. node:lts is pulled once and reused, and since it already has git and the build toolchain, there's no in-container install on any run.
  • rsync --delete, not cp -r. rsync copies only changed files and removes files deleted upstream, keeping DEST an exact mirror. Plain cp would leave stale, deleted pages behind forever.
  • The persistent .docusaurus/ cache also shaves time off the build step.

A routine update becomes roughly "fetch new commits + build" instead of a full clone, install, and copy.

Troubleshooting​

destination path already exists

An earlier version re-cloned into a fixed directory. The current script updates a persistent WORKDIR instead, so this shouldn't recur. If you still see it, the working directory exists but has no .git — remove it and rerun: rm -rf "$WORKDIR" && ./docusaurus-deploy.sh.

This Docusaurus site is outside any Git worktree

The site has the "last updated" feature enabled, so Docusaurus runs git log on each .mdx. This fails only if the build image has no git binary — node:lts-alpine was the culprit. node:lts (Debian) includes git, so the error shouldn't appear. If you deliberately switch back to an image without git, either install it in the container or disable the feature in the docs preset options:

showLastUpdateTime: false,
showLastUpdateAuthor: false,
gyp ERR! find Python / native module fails to build

A dependency ships a native addon (e.g. better-sqlite3) with no prebuilt binary for this platform, so npm compiles it with node-gyp, which needs Python and a C/C++ toolchain. node:lts includes both, so this builds cleanly. It only surfaces on a stripped-down image like node:lts-alpine; if you must use Alpine, add the toolchain with apk add --no-cache python3 make g++.

git (no such package) / DNS: transient error

Alpine-only: apk couldn't reach its package mirror to install git or the toolchain. Switching to node:lts removes the apk step entirely, so this can't occur. If you keep an Alpine-based image and hit it, --dns 8.8.8.8 helps docker run; a docker build step needs the DNS fix at the daemon level.

Landing page (project index)​

With several projects deployed side by side under /var/www/html/docs/, a landing page at the root ties them together. generate-index.sh scans that directory and writes an index.html listing every project as a Bootstrap 5 card. The deploy script calls it as its last step, so the index refreshes on every deploy.

A plain static page can't enumerate folders on its own — a browser has no filesystem access — which is why this is a generator run on the host rather than client-side JavaScript. It's also why the alternative, nginx's autoindex, is avoided: it exposes a raw file listing rather than a curated page.

generate-index.sh
#!/bin/sh
set -e

# Scans DOCS_ROOT for project subfolders (those containing an index.html),
# pulls each site's hero title/subtitle out of its built homepage, and writes
# a Bootstrap 5 card grid listing them.
DOCS_ROOT="${DOCS_ROOT:-/var/www/html/docs}" # where project subfolders live
OUTPUT="${OUTPUT:-$DOCS_ROOT/index.html}" # landing page to write
LINK_PREFIX="${LINK_PREFIX:-/}" # projects are served at ${LINK_PREFIX}<name>/
SITE_TITLE="${SITE_TITLE:-Documentation}"
BOOTSTRAP_CSS="${BOOTSTRAP_CSS:-https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css}"
FAVICON="${FAVICON:-https://raw.githubusercontent.com/facebook/docusaurus/refs/heads/main/examples/classic/static/img/favicon.ico}"

# Pull the text inside an element carrying a given class. Handles quoted or
# unquoted attributes, extra classes, and a missing close tag (stops at next <).
# Needs GNU grep (-P). Returns empty if not found.
extract() {
grep -oP "$2"'[^>]*>\K[^<]*' "$1" 2>/dev/null | head -n1
}

prettify() {
echo "$1" | tr '_-' ' ' \
| awk '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1)) substr($i,2)}}1'
}

# Header (quoted heredoc: nothing expands here; placeholders filled by sed after)
cat > "$OUTPUT" <<'HEAD'
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__SITE_TITLE__</title>
<link rel="icon" href="__FAVICON__">
<link href="__BOOTSTRAP_CSS__" rel="stylesheet">
</head>
<body class="bg-body-tertiary">
<main class="container py-5">
<h1 class="mb-1">__SITE_TITLE__</h1>
<p class="text-body-secondary mb-4">Available documentation sites</p>
<div class="row row-cols-1 row-cols-md-2 g-4">
HEAD

found=0
for dir in "$DOCS_ROOT"/*/; do
[ -f "${dir}index.html" ] || continue # skip folders with no built site
name=$(basename "$dir")

title=$(extract "${dir}index.html" 'hero__title')
subtitle=$(extract "${dir}index.html" 'hero__subtitle')
[ -n "$title" ] || title=$(prettify "$name") # fall back to folder name

# %s args keep arbitrary title/subtitle text out of the format string
{
printf ' <div class="col">\n'
printf ' <div class="card h-100 shadow-sm">\n'
printf ' <div class="card-body">\n'
printf ' <h5 class="card-title">%s</h5>\n' "$title"
printf ' <p class="card-text text-body-secondary">%s</p>\n' "$subtitle"
printf ' </div>\n'
printf ' <div class="card-footer bg-transparent d-flex align-items-center">\n'
printf ' <a href="%s%s/" class="btn btn-primary btn-sm stretched-link">Abrir</a>\n' "$LINK_PREFIX" "$name"
printf ' <span class="ms-auto small text-body-secondary font-monospace">%s%s/</span>\n' "$LINK_PREFIX" "$name"
printf ' </div>\n'
printf ' </div>\n'
printf ' </div>\n'
} >> "$OUTPUT"
found=$((found + 1))
done

if [ "$found" -eq 0 ]; then
printf ' <div class="col"><div class="alert alert-secondary mb-0">No documentation sites deployed yet.</div></div>\n' >> "$OUTPUT"
fi

cat >> "$OUTPUT" <<TAIL
</div>
<footer class="text-body-secondary small mt-5">Generated $(date -u '+%Y-%m-%d %H:%M UTC') &middot; ${found} site(s)</footer>
</main>
<script>document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');</script>
</body>
</html>
TAIL

# Fill placeholders kept out of the heredoc (avoids quoting/escaping headaches)
sed -i "s|__SITE_TITLE__|${SITE_TITLE}|g; s|__BOOTSTRAP_CSS__|${BOOTSTRAP_CSS}|g; s|__FAVICON__|${FAVICON}|g" "$OUTPUT"

echo "Wrote $OUTPUT (${found} site(s))"

Everything is overridable by environment variable, so no edits are needed to retarget it:

DOCS_ROOT=/var/www/html/docs OUTPUT=/var/www/html/docs/index.html \
SITE_TITLE="docs.mini.pc" sh generate-index.sh

Card titles come from each site's hero​

Instead of showing folder names, the generator reads each project's built homepage and pulls the text out of its hero__title and hero__subtitle elements — the heading and tagline from the Docusaurus landing page. The extract() helper matches the class, skips the rest of the tag, and captures up to the next <, so it copes with minified output: unquoted attributes (class=hero__title), extra classes, and even a missing closing </p>. If a homepage has no hero, the card falls back to the (prettified) folder name.

Needs GNU grep

extract() uses grep -oP (PCRE), which is GNU grep only. That's fine on the host, which is Debian-based. Don't run this script inside the Alpine container, whose BusyBox grep has no -P.

Link path must match baseUrl

Cards link to /<name>/ (root-relative), because each project's baseUrl is /$PJT_NAME/ and the nginx alias serves the site at docs.mini.pc/<name>/ — the /docs/ in the filesystem path is not part of the URL. If you ever serve projects under a /docs/ URL prefix instead, set LINK_PREFIX=/docs/ so the links keep matching.

Bootstrap and favicon load from the network

The generator itself never touches the network, but the page it writes pulls Bootstrap from cdn.jsdelivr.net and the favicon from raw.githubusercontent.com in the viewer's browser. If docs.mini.pc is a LAN box whose users have no internet, neither will load — the page renders unstyled and without an icon. Vendor both locally once and point the script at the copies:

# one-time, on a machine with internet:
curl -L -o /var/www/html/docs/bootstrap.min.css \
https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css
# one-time, on a machine with internet:
curl -L -o /var/www/html/docs/favicon.ico \
https://raw.githubusercontent.com/facebook/docusaurus/refs/heads/main/examples/classic/static/img/favicon.ico
# then generate against the local files:
BOOTSTRAP_CSS=/bootstrap.min.css FAVICON=/favicon.ico sh generate-index.sh

Deploy everything (deploy-all.sh)​

Once several projects each have their own deploy script (iiot-docs.sh, tcc-docs.sh, …), deploy-all.sh runs them all in one pass. It lives in the same directory, discovers every *.sh sibling automatically (skipping itself and generate-index.sh), and needs no edit when you add a new project.

nano deploy-all.sh
deploy-all.sh
#!/bin/sh
# Runs every per-project deploy script in this directory, in order, continuing
# past any that fail; regenerates the landing page; and writes a Bootstrap-
# styled logs.html next to index.html in the web root for later reference.
# Exit status is non-zero if any project failed.
set -u

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
SELF=$(basename "$0")

DOCS_ROOT="${DOCS_ROOT:-/var/www/html/docs}"
SITE_TITLE="${SITE_TITLE:-docs.mini.pc}"
SUDO="${SUDO-sudo}" # set SUDO= (empty) when already root
LOG_HTML="${LOG_HTML:-$DOCS_ROOT/logs.html}" # written alongside index.html
BOOTSTRAP_CSS="${BOOTSTRAP_CSS:-https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css}"
FAVICON="${FAVICON:-https://raw.githubusercontent.com/facebook/docusaurus/refs/heads/main/examples/classic/static/img/favicon.ico}"

RUN="$(mktemp -d)"
trap 'rm -rf "$RUN"' EXIT
BODY="$RUN/body.html"
: > "$BODY"

# HTML-escape stdin; strip ANSI colour codes and stray carriage returns first.
clean() {
sed "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" | tr -d '\r' \
| sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
}

started="$(date '+%Y-%m-%d %H:%M:%S %Z')"
ok_list=""
fail_list=""
total=0

for script in ./*.sh; do
base=$(basename "$script")
[ "$base" = "$SELF" ] && continue
[ "$base" = "generate-index.sh" ] && continue
[ -f "$script" ] || continue

total=$((total + 1))
printf '\n========== [%d] %s ==========\n' "$total" "$base"
log="$RUN/out.log"
start=$(date +%s)

# tee gives live console output; the rc file preserves the child's real
# exit code (the pipeline's own status would be tee's, not the script's).
{ sh "$script"; echo $? > "$RUN/rc"; } 2>&1 | tee "$log"
rc=$(cat "$RUN/rc")
dur=$(( $(date +%s) - start ))

if [ "$rc" -eq 0 ]; then
status="OK"; badge="text-bg-success"; ok_list="$ok_list $base"; openattr=""
else
status="FAIL (exit $rc)"; badge="text-bg-danger"; fail_list="$fail_list $base"; openattr=" open"
fi
printf -- '---------- %s: %s (%ds) ----------\n' "$base" "$status" "$dur"

{
printf '<details class="card mb-2 shadow-sm"%s>' "$openattr"
printf '<summary class="card-header d-flex align-items-center gap-2"><span class="fw-semibold">%s</span><span class="badge %s">%s</span><span class="ms-auto text-body-secondary small">%ds</span></summary>' \
"$base" "$badge" "$status" "$dur"
printf '<pre class="log card-body mb-0 small"><code>'
clean < "$log"
printf '</code></pre></details>\n'
} >> "$BODY"
done

# Regenerate the landing page once, capturing its output into the log too
printf '\n========== landing page ==========\n'
lp="$RUN/lp.log"
if [ -f "$SCRIPT_DIR/generate-index.sh" ]; then
{ $SUDO env DOCS_ROOT="$DOCS_ROOT" SITE_TITLE="$SITE_TITLE" \
BOOTSTRAP_CSS="$BOOTSTRAP_CSS" FAVICON="$FAVICON" \
sh "$SCRIPT_DIR/generate-index.sh"; echo $? > "$RUN/rc"; } 2>&1 | tee "$lp"
lprc=$(cat "$RUN/rc")
else
echo "generate-index.sh not found, skipping" | tee "$lp"; lprc=0
fi
if [ "$lprc" -eq 0 ]; then lpbadge="text-bg-success"; lpstatus="OK"
else lpbadge="text-bg-danger"; lpstatus="FAIL (exit $lprc)"; fi
{
printf '<details class="card mb-2 shadow-sm"><summary class="card-header d-flex align-items-center gap-2"><span class="fw-semibold">landing page</span><span class="badge %s">%s</span></summary>' \
"$lpbadge" "$lpstatus"
printf '<pre class="log card-body mb-0 small"><code>'; clean < "$lp"; printf '</code></pre></details>\n'
} >> "$BODY"

ok_n=$(echo "$ok_list" | wc -w)
fail_n=$(echo "$fail_list" | wc -w)

# --- build logs.html, then place it next to index.html --------------------
{
printf '<!DOCTYPE html>\n<html lang="en">\n<head>\n'
printf '<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n'
printf '<title>Deploy log &middot; %s</title>\n' "$SITE_TITLE"
printf '<link rel="icon" href="%s">\n' "$FAVICON"
printf '<link href="%s" rel="stylesheet">\n' "$BOOTSTRAP_CSS"
cat <<'HTMLCSS'
<style>
details > summary { cursor: pointer; list-style: none; }
details > summary::-webkit-details-marker { display: none; }
pre.log { max-height: 480px; overflow: auto; white-space: pre; }
</style>
</head>
<body class="bg-body-tertiary">
<main class="container py-4">
<h1 class="h4 mb-3">Deploy log</h1>
HTMLCSS
if [ -n "$fail_list" ]; then
printf '<div class="alert alert-danger" role="alert"><strong>%d failed.</strong> Total %d &middot; OK %d &middot; Failed %d<br><span class="small font-monospace">Failed:%s</span></div>\n' \
"$fail_n" "$total" "$ok_n" "$fail_n" "$fail_list"
else
printf '<div class="alert alert-success" role="alert">All %d project(s) built successfully.</div>\n' "$total"
fi
printf '<p class="text-body-secondary small mb-3">Run: %s</p>\n' "$started"
cat "$BODY"
cat <<'HTMLTAIL'
</main>
<script>document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');</script>
</body>
</html>
HTMLTAIL
} > "$RUN/logs.html"

$SUDO cp "$RUN/logs.html" "$LOG_HTML"

printf '\n===================== SUMMARY =====================\n'
printf 'Total: %d OK: %d Failed: %d\n' "$total" "$ok_n" "$fail_n"
[ -n "$ok_list" ] && printf 'OK :%s\n' "$ok_list"
[ -n "$fail_list" ] && printf 'FAILED :%s\n' "$fail_list"
printf 'Log written to %s\n' "$LOG_HTML"
[ -n "$fail_list" ] && exit 1
echo "All projects built successfully."

chmod +x deploy-all.sh # first time only
./deploy-all.sh

Its behavior is shaped by two things that matter for an unattended batch:

  • It does not stop on the first failure. Each project script runs under its own set -e, but the wrapper captures the exit code, records the project as failed, and moves on — so one broken build (a missing native dep, say) doesn't block the other nine. At the end it prints a pass/fail summary and exits non-zero if any project failed, which is what lets you wire it into cron or a health check.
  • It regenerates the landing page once, at the end, reflecting whatever built successfully.

Environment variables (all overridable) drive the shared configuration:

VariableDefaultPurpose
DOCS_ROOT/var/www/html/docsWhere projects and both pages live
SITE_TITLEdocs.mini.pcHeading on the landing page and log
SUDOsudoSet SUDO= (empty) when already root
LOG_HTML$DOCS_ROOT/logs.htmlWhere the HTML log is written
BOOTSTRAP_CSSjsDelivr CDN URLStylesheet for the landing page and log
FAVICONDocusaurus favicon URLIcon for the landing page and log
Redundant landing-page runs

Each individual project script also calls generate-index.sh at its own end, so during a full deploy-all.sh run the index is regenerated several times (once per project, plus the final explicit one). It's a cheap directory scan, so this is harmless. If you only ever deploy via deploy-all.sh, you can drop the generate-index.sh line from the individual scripts and let the wrapper own it.

Build logs (logs.html)​

deploy-all.sh writes a Bootstrap-styled logs.html next to index.html in the web root, so the last run is viewable at docs.mini.pc/logs.html and matches the landing page's look. Each project's combined stdout and stderr is streamed live to the console and captured; the page renders one collapsible card per project with a status badge and build duration, and failed sections open by default so you land on the errors.

Captured output is ANSI-stripped and HTML-escaped before embedding, so build noise (colour codes, <>& in log lines) can't corrupt the page. The collapse uses native <details>, so only the Bootstrap stylesheet is needed — no bootstrap.bundle.js.

logs.html is served — mind its contents

Because it sits in the web root, logs.html is reachable by anyone who can load the site, and it contains full build paths and any error output. That's fine for a trusted LAN box. If it isn't, keep it out of the web root (LOG_HTML=/opt/docusaurus-builder/logs.html) or restrict /logs.html in nginx. Each run overwrites the file rather than keeping history.

Same offline caveat, now served

logs.html pulls Bootstrap and the favicon from the network just like the landing page. On an offline LAN, vendor both locally and pass the same BOOTSTRAP_CSS=/bootstrap.min.css FAVICON=/favicon.ico — deploy-all.sh applies them to the log and forwards them to generate-index.sh for the landing page, so one setting covers both pages.

Serving the site (nginx)​

The static files at DEST need a web server pointing at them. A minimal nginx config that serves a project (matching BASE_URL = /iiot-docs/ and DEST = /var/www/html/docs/iiot-docs), plus the generated landing page and build log from the docs root:

server {
listen 80;
server_name docs.mini.pc;

# Landing page and build log live in the docs root, served at "/"
location = / { root /var/www/html/docs; try_files /index.html =404; }
location = /logs.html { root /var/www/html/docs; }

# One block per project (all derived from PJT_NAME)
location /iiot-docs/ {
alias /var/www/html/docs/iiot-docs/;
index index.html;
try_files $uri $uri/ =404;
}
}

Drop the location = /logs.html line (or restrict it) if you'd rather not expose the build log — see the warning above.

The alias path, the location prefix, and BASE_URL all have to agree — they're all functions of PJT_NAME, so keep them consistent when you add a new project. The landing page links to those same /<name>/ paths, so a project only appears (and resolves) once its location block is in place.