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 main on 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