Guide
Automating Markdown to PDF: CI/CD, Docker, Git Hooks and APIs
Published July 30, 2026
Automating conversion is worth doing when the output has to be current, consistent, or produced too often to do by hand. It is worth not doing when the document needs a human to look at it before it goes out — a resume, a client proposal, anything where layout matters and you cannot see the result.
This guide covers the automation patterns that hold up in production, with the failure modes each one has.
What to automate
Good candidates:
- Documentation released with software. A manual PDF built from
mainon every tag is current by construction. - Recurring reports from data that changes — weekly metrics, monthly summaries.
- Release notes generated from commit history.
- Compliance artefacts where you need a dated snapshot at a known point.
- Bulk conversion of a documentation tree.
Poor candidates:
- Anything layout-sensitive that nobody checks. Automation happily produces a resume with an orphaned line on page two, forever.
- Documents needing editorial judgement. Generated release notes are a draft; see the team documentation guide.
- One-off conversions. Setting up a pipeline for a document you will convert once is a net loss.
Docker for reproducible builds
Do this first. The commonest automation failure is “works on my machine” — different Pandoc version, different fonts installed, different LaTeX packages — producing different output on CI. A pinned image eliminates the entire class.
A WeasyPrint image
Lighter than the alternatives and the right default for document work:
FROM python:3.13-slim AS base
# WeasyPrint's rendering dependencies, plus fonts. Pinning fonts matters as
# much as pinning packages — a missing font silently changes pagination.
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libffi8 \
fonts-liberation \
fonts-dejavu-core \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* \
&& fc-cache -f
RUN pip install --no-cache-dir \
weasyprint==63.1 \
markdown==3.7 \
pygments==2.18.0
WORKDIR /work
# Run as a non-root user; rendering untrusted input as root is a bad idea.
RUN useradd --create-home --uid 10001 render
USER render
ENTRYPOINT ["python", "/opt/convert.py"]
fonts-noto-color-emoji is the fix for the “emoji work locally but are boxes in CI” report — see the typography guide. fc-cache -f after installing fonts is needed or fontconfig may not find them.
A Pandoc image
If you need citations, use the official image rather than building your own:
FROM pandoc/latex:3.6.2
# Only the LaTeX packages you actually need — the base image is already ~1GB.
RUN tlmgr update --self && \
tlmgr install \
collection-fontsrecommended \
csquotes \
biblatex \
titlesec
WORKDIR /data
Pin the tag. pandoc/latex:latest will change under you and break a working pipeline at an inconvenient moment.
A Puppeteer image
When you need JavaScript-rendered content:
FROM node:22-slim
# Chromium's shared library dependencies.
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
fonts-liberation \
fonts-noto-color-emoji \
libnss3 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
&& rm -rf /var/lib/apt/lists/* \
&& fc-cache -f
# Use the system Chromium rather than downloading another copy.
ENV PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN useradd --create-home --uid 10001 render
USER render
CMD ["node", "convert.js"]
Two things that bite. --disable-dev-shm-usage in your launch args — Docker gives /dev/shm 64 MB by default and Chromium crashes without this flag (or run the container with --shm-size=1g). And PUPPETEER_SKIP_DOWNLOAD avoids shipping two Chromium copies in the image.
GitHub Actions
A complete workflow that builds PDFs, uploads them as artefacts, and attaches them to releases:
name: Build documentation PDFs
on:
push:
branches: [main]
paths: ["docs/**/*.md", "docs/print.css", ".github/workflows/pdf.yml"]
pull_request:
paths: ["docs/**/*.md", "docs/print.css"]
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pdf-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
container:
image: ghcr.io/${{ github.repository }}/doc-builder:1.4.0
steps:
- uses: actions/checkout@v4
- name: Convert
run: |
set -euo pipefail
mkdir -p build
shopt -s nullglob
found=0
for f in docs/**/*.md; do
out="build/$(basename "${f%.md}").pdf"
echo "::group::$f"
python /opt/convert.py "$f" --css docs/print.css --out "$out"
echo "::endgroup::"
found=$((found+1))
done
if [ "$found" -eq 0 ]; then
echo "::error::No Markdown files matched" && exit 1
fi
echo "Built $found PDFs"
- name: Verify output is valid
run: |
set -euo pipefail
for pdf in build/*.pdf; do
# A PDF under 1KB is almost certainly a failed render.
size=$(stat -c%s "$pdf")
if [ "$size" -lt 1024 ]; then
echo "::error::$pdf is only ${size} bytes" && exit 1
fi
# Confirm it parses and report the page count.
pages=$(pdfinfo "$pdf" | awk '/^Pages:/ {print $2}')
echo "$pdf — ${pages} pages, ${size} bytes"
done
- uses: actions/upload-artifact@v4
with:
name: documentation-pdfs
path: build/*.pdf
retention-days: 30
if-no-files-found: error
- name: Attach to release
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ github.event.release.tag_name }}" build/*.pdf --clobber
Several things there are deliberate.
paths filters stop the workflow running on unrelated commits.
concurrency with cancel-in-progress avoids piling up renders when someone pushes three times in a minute.
The container: key runs every step inside your pinned image, so no apt-get install in the workflow and no version drift.
The verification step is the important one. A failed render often produces a valid-but-empty PDF, and without this check the pipeline goes green while shipping blank documents. Checking file size and parsing with pdfinfo catches it. Reporting page counts also makes an unexpected pagination change visible in the log.
if-no-files-found: error turns a silent no-op into a failure.
permissions: contents: read at the top, with the release step relying on the default token — least privilege by default.
Commenting page counts on pull requests
Genuinely useful: surfacing pagination changes in review.
- name: Report page counts
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
{
echo "### Generated PDFs"
echo
echo "| Document | Pages | Size |"
echo "| --- | ---: | ---: |"
for pdf in build/*.pdf; do
pages=$(pdfinfo "$pdf" | awk '/^Pages:/ {print $2}')
size=$(du -h "$pdf" | cut -f1)
echo "| $(basename "$pdf") | $pages | $size |"
done
} > comment.md
gh pr comment "${{ github.event.pull_request.number }}" --body-file comment.md
A reviewer seeing “manual.pdf: 47 pages” when it was 31 knows something changed structurally.
GitLab CI
The same pipeline in GitLab’s model:
stages: [build, verify, publish]
variables:
DOC_IMAGE: $CI_REGISTRY_IMAGE/doc-builder:1.4.0
.docs_changes: &docs_changes
- docs/**/*.md
- docs/print.css
build:pdf:
stage: build
image: $DOC_IMAGE
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes: *docs_changes
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes: *docs_changes
- if: $CI_COMMIT_TAG
script:
- set -euo pipefail
- mkdir -p build
- |
for f in docs/*.md; do
python /opt/convert.py "$f" --css docs/print.css \
--out "build/$(basename "${f%.md}").pdf"
done
artifacts:
paths: [build/]
expire_in: 30 days
verify:pdf:
stage: verify
image: $DOC_IMAGE
needs: [build:pdf]
script:
- |
for pdf in build/*.pdf; do
[ "$(stat -c%s "$pdf")" -lt 1024 ] && { echo "$pdf too small"; exit 1; }
pdfinfo "$pdf" > /dev/null || { echo "$pdf is corrupt"; exit 1; }
echo "$pdf OK — $(pdfinfo "$pdf" | awk '/^Pages:/ {print $2}') pages"
done
publish:pages:
stage: publish
needs: [verify:pdf]
rules:
- if: $CI_COMMIT_TAG
script:
- mkdir -p public && cp build/*.pdf public/
artifacts:
paths: [public]
Git hooks
Hooks run on the developer’s machine, which makes them good for fast checks and bad for anything slow.
The case against converting in a hook
A pre-commit hook that renders PDFs is a bad idea for three reasons: rendering takes seconds to tens of seconds and developers will start using --no-verify; the binary PDF ends up committed, bloating history and conflicting on every parallel change; and it does not scale to CI anyway.
Generate PDFs in CI. Validate in hooks.
A useful pre-commit hook
#!/usr/bin/env bash
# .git/hooks/pre-commit — validate Markdown without rendering
set -euo pipefail
staged=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.md$' || true)
[ -z "$staged" ] && exit 0
fail=0
while IFS= read -r file; do
# 1. Unresolved placeholders
if grep -nE 'TKTK|XXX|FIXME|TODO:' "$file"; then
echo " ✗ $file: unresolved placeholder above" >&2
fail=1
fi
# 2. Local image references that do not exist
while IFS= read -r img; do
dir=$(dirname "$file")
if [ ! -f "$dir/$img" ] && [ ! -f "$img" ]; then
echo " ✗ $file: missing image '$img'" >&2
fail=1
fi
done < <(grep -oE '!\[[^]]*\]\(([^)]+)\)' "$file" \
| sed -E 's/.*\(([^)]+)\)/\1/' \
| grep -vE '^(https?:|data:)' || true)
# 3. Frontmatter must be present and well-formed
if [ "$(head -1 "$file")" != "---" ]; then
echo " ✗ $file: missing YAML frontmatter" >&2
fail=1
fi
done <<< "$staged"
if [ "$fail" -ne 0 ]; then
echo "" >&2
echo "Markdown validation failed. Fix the above or commit with --no-verify." >&2
exit 1
fi
Fast, catches real problems, and does not tempt anyone to bypass it.
Pre-push, for something slower
If you want a render check, pre-push is the better hook — it runs less often and developers expect it to take a moment:
#!/usr/bin/env bash
# .git/hooks/pre-push — confirm docs still render
set -euo pipefail
changed=$(git diff --name-only "@{push}..HEAD" 2>/dev/null | grep -E '^docs/.*\.md$' || true)
[ -z "$changed" ] && exit 0
echo "Verifying $(echo "$changed" | wc -l) changed document(s) render..."
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
while IFS= read -r f; do
if ! docker run --rm -v "$PWD:/work:ro" -v "$tmp:/out" \
doc-builder:1.4.0 "$f" --out "/out/$(basename "$f").pdf" 2>&1; then
echo "✗ $f failed to render" >&2
exit 1
fi
done <<< "$changed"
echo "✓ All documents render"
Note hooks are not versioned by Git. Use core.hooksPath pointing at a committed directory, or a manager like pre-commit, so the team actually gets them:
git config core.hooksPath .githooks
Webhooks and queue-based APIs
For conversion triggered by an external event — a CMS publish, a form submission, a scheduled job.
Why synchronous conversion is the wrong shape
The obvious design is an endpoint that accepts Markdown and returns a PDF. It works until it does not:
- Rendering takes 1–30 seconds, so the caller holds a connection open and may time out
- Chromium uses 200–500 MB per render, so concurrency exhausts memory fast
- A traffic spike has no back pressure — everything degrades at once
- A failed render loses the request entirely, with no retry
Accept the job, return an ID, render on a worker.
The API shape
POST /v1/jobs → 202 Accepted, { "id": "job_8fH2", "status": "queued" }
GET /v1/jobs/job_8fH2 → 200 OK, { "status": "done", "url": "https://..." }
// api.js — accepts work, does none of it
import express from "express";
import { Queue } from "bullmq";
import { randomUUID } from "node:crypto";
const app = express();
app.use(express.json({ limit: "2mb" }));
const queue = new Queue("pdf", {
connection: { host: process.env.REDIS_HOST, port: 6379 },
});
app.post("/v1/jobs", async (req, res) => {
const { source, options } = req.body ?? {};
if (typeof source !== "string" || !source.trim()) {
return res.status(400).json({ error: "source required" });
}
const id = `job_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
await queue.add("convert", { id, source, options }, {
jobId: id,
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
});
res.status(202)
.location(`/v1/jobs/${id}`)
.json({ id, status: "queued" });
});
app.get("/v1/jobs/:id", async (req, res) => {
const job = await queue.getJob(req.params.id);
if (!job) return res.status(404).json({ error: "not found" });
const state = await job.getState();
res.json({
id: job.id,
status: state,
...(state === "completed" ? { url: job.returnvalue?.url } : {}),
...(state === "failed" ? { error: job.failedReason } : {}),
});
});
// worker.js — bounded concurrency, one browser
import { Worker } from "bullmq";
import PdfRenderer from "./converter.js";
import { uploadToStorage } from "./storage.js";
const renderer = new PdfRenderer();
await renderer.init("./print.css");
const worker = new Worker(
"pdf",
async (job) => {
const pdf = await renderer.render(job.data.source, job.data.options);
const url = await uploadToStorage(`${job.id}.pdf`, pdf);
return { url, bytes: pdf.length };
},
{
connection: { host: process.env.REDIS_HOST, port: 6379 },
concurrency: 4, // per worker process — tune to memory
limiter: { max: 20, duration: 1000 },
},
);
worker.on("failed", (job, err) => {
console.error(`job ${job?.id} failed:`, err.message);
});
for (const sig of ["SIGTERM", "SIGINT"]) {
process.on(sig, async () => {
await worker.close(); // finishes in-flight jobs first
await renderer.close();
process.exit(0);
});
}
What this buys you: back pressure (the queue absorbs spikes), automatic retry with backoff, independent scaling of API and workers, and graceful shutdown that does not drop in-flight work.
Verifying webhook signatures
If an external service triggers your pipeline, verify the request actually came from it:
import { createHmac, timingSafeEqual } from "node:crypto";
// Raw body is required — JSON.parse then re-stringify will not match.
app.post("/webhook",
express.raw({ type: "application/json", limit: "2mb" }),
(req, res, next) => {
const signature = req.get("X-Signature-256") ?? "";
const expected = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).json({ error: "invalid signature" });
}
req.parsed = JSON.parse(req.body.toString("utf8"));
next();
},
async (req, res) => {
await queue.add("convert", { source: req.parsed.content });
res.status(202).json({ status: "queued" });
},
);
timingSafeEqual rather than === avoids leaking the signature through timing. The length check first is required because timingSafeEqual throws on mismatched lengths. And express.raw matters — signatures are computed over the exact bytes sent.
Scheduled reports
For recurring output, a scheduled job that fetches data, renders and delivers:
# .github/workflows/weekly-report.yml
name: Weekly metrics report
on:
schedule:
- cron: "0 6 * * MON" # Mondays 06:00 UTC
workflow_dispatch: # always add this — you will want to test it
permissions:
contents: read
jobs:
report:
runs-on: ubuntu-latest
container:
image: ghcr.io/${{ github.repository }}/doc-builder:1.4.0
steps:
- uses: actions/checkout@v4
- name: Fetch data and render
env:
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
run: |
set -euo pipefail
python scripts/fetch_metrics.py > build/report.md
python /opt/convert.py build/report.md \
--css docs/report.css --out build/report.pdf
- name: Email it
env:
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
run: python scripts/send_report.py build/report.pdf
Always include workflow_dispatch. Debugging a cron-only workflow by waiting until Monday is not a good use of anyone’s week.
Monitoring
Things worth alerting on, because a broken document pipeline fails quietly:
Render duration. A creeping p95 usually means documents are growing. A sudden spike means something changed.
Output size and page count. A PDF that suddenly halves in size, or drops from 47 to 3 pages, indicates a broken render that still exited zero. This is the highest-value check and almost nobody has it.
Failure rate by document. One document failing consistently is a content problem; all documents failing is infrastructure.
Zombie Chromium processes. They accumulate on unclean shutdown until the container dies.
// Minimal instrumentation
const started = Date.now();
const pdf = await renderer.render(source);
const pages = await countPages(pdf); // e.g. via pdf-lib
metrics.histogram("pdf.render_ms", Date.now() - started, { doc: name });
metrics.gauge("pdf.bytes", pdf.length, { doc: name });
metrics.gauge("pdf.pages", pages, { doc: name });
if (pdf.length < 1024) {
metrics.increment("pdf.suspiciously_small", { doc: name });
}
Try it yourself
For the documents that should not be automated — anything where you need to see the layout before it goes out — the editor here gives you a live paginated preview and runs entirely in your browser. It pairs naturally with an automated pipeline: automate the documentation, check the client-facing work by eye.
Related: generating PDFs in code for the renderer implementations these pipelines call, the Pandoc comparison for picking an engine, and team documentation for release notes generation.
About this guide
Written and maintained by the developer of MarkdownToFile — the browser-based converter this site runs on. Techniques described here are tested against the engines named in the text (Chromium's print pipeline, Paged.js, WeasyPrint), and engine limitations are stated rather than glossed over. Corrections are welcome via the contact page; more about the project on the about page.
Try it yourself — free, no signup
Convert your Markdown to a polished PDF right in your browser.
Open the editor