FormaTeX

\begin{article}

draft — not published

Compiling LaTeX in CI/CD Pipelines with FormatEx

How to generate PDFs automatically in GitHub Actions, GitLab CI, and other pipelines — no TeX Live installation, no Docker image, just an API call.

·6 min read·
Compiling LaTeX in CI/CD Pipelines with FormatEx

The traditional approach to LaTeX in CI is painful: pull a 4 GB TeX Live Docker image, wait for it to cache, watch it break when the image updates. FormatEx replaces all of that with a single HTTP request.

The Pattern

Every pipeline boils down to this:

bash
curl -X POST https://api.formatex.io/api/v1/compile \
  -H "X-API-Key: $FORMATEX_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"content\": $(cat document.tex | jq -Rs .), \"engine\": \"pdflatex\"}" \
  --output output.pdf

Store the API key as a CI secret, read the .tex file, POST it, save the response as a PDF.

jq -Rs . reads a file as a raw string and produces a properly escaped JSON string — the cleanest way to embed multi-line LaTeX in a curl one-liner.

GitHub Actions

yaml
name: Build PDF

on:
  push:
    paths:
      - "docs/**.tex"
  workflow_dispatch:

jobs:
  compile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Compile LaTeX to PDF
        env:
          FORMATEX_KEY: ${{ secrets.FORMATEX_KEY }}
        run: |
          curl -s -X POST https://api.formatex.io/api/v1/compile \
            -H "X-API-Key: $FORMATEX_KEY" \
            -H "Content-Type: application/json" \
            -d "{\"content\": $(cat docs/report.tex | jq -Rs .), \"engine\": \"pdflatex\"}" \
            --output docs/report.pdf

      - name: Upload PDF artifact
        uses: actions/upload-artifact@v4
        with:
          name: report
          path: docs/report.pdf

No Docker, no cache, no TeX Live. The job runs in seconds.

Uploading to a Release

yaml
      - name: Upload to release
        uses: softprops/action-gh-release@v1
        with:
          files: docs/report.pdf

Committing the PDF back to the repo

yaml
      - name: Commit generated PDF
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add docs/report.pdf
          git diff --cached --quiet || git commit -m "chore: regenerate report.pdf"
          git push

Auto-committing PDFs back to the repo will re-trigger the workflow. Add a [skip ci] suffix to the commit message or use a path filter to exclude *.pdf files from the trigger.

GitLab CI

yaml
compile-pdf:
  image: alpine
  before_script:
    - apk add --no-cache curl jq
  script:
    - |
      curl -s -X POST https://api.formatex.io/api/v1/compile \
        -H "X-API-Key: $FORMATEX_KEY" \
        -H "Content-Type: application/json" \
        -d "{\"content\": $(cat report.tex | jq -Rs .), \"engine\": \"pdflatex\"}" \
        --output report.pdf
  artifacts:
    paths:
      - report.pdf
    expire_in: 30 days
  only:
    changes:
      - "*.tex"

Node.js Script (for complex workflows)

When you need error handling, multiple files, or conditional logic, a script is cleaner than shell. See the full TypeScript client with error handling and Next.js integration for a production-ready example:

typescript
import fs from "fs";
import path from "path";

async function compile(texPath: string, outputPath: string) {
  const content = fs.readFileSync(texPath, "utf-8");

  const response = await fetch("https://api.formatex.io/api/v1/compile", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.FORMATEX_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ content, engine: "pdflatex" }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Compilation failed:\n${error.log ?? error.error}`);
  }

  const buffer = await response.arrayBuffer();
  fs.writeFileSync(outputPath, Buffer.from(buffer));
  console.log(`Written: ${outputPath}`);
}

// Compile all .tex files in docs/
const docsDir = path.join(process.cwd(), "docs");
const texFiles = fs.readdirSync(docsDir).filter((f) => f.endsWith(".tex"));

for (const file of texFiles) {
  const slug = file.replace(".tex", "");
  await compile(
    path.join(docsDir, file),
    path.join(docsDir, `${slug}.pdf`)
  );
}

Handling Errors in CI

The API returns HTTP 400 with a JSON body when compilation fails. For a complete reference of all LaTeX API error codes including 422 compile failures and 429 rate limits, see the dedicated guide.

json
{
  "error": "compilation failed",
  "log": "! Undefined control sequence.\nl.12 \\badcommand\n..."
}

In shell, check the exit code and parse the log:

bash
HTTP_STATUS=$(curl -s -o response.bin -w "%{http_code}" \
  -X POST https://api.formatex.io/api/v1/compile \
  -H "X-API-Key: $FORMATEX_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"content\": $(cat report.tex | jq -Rs .), \"engine\": \"pdflatex\"}")

if [ "$HTTP_STATUS" != "200" ]; then
  echo "Compilation failed (HTTP $HTTP_STATUS):"
  cat response.bin | jq -r '.log'
  exit 1
fi

mv response.bin report.pdf

Syntax Check as a CI Pre-Validation Step

Before running a full compilation (which counts against your monthly quota), validate your LaTeX syntax with the check endpoint:

bash
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
  -X POST https://api.formatex.io/api/v1/compile/check \
  -H "X-API-Key: $FORMATEX_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"content\": $(cat report.tex | jq -Rs .)}")

if [ "$HTTP_STATUS" != "200" ]; then
  echo "LaTeX syntax check failed"
  exit 1
fi

echo "Syntax valid — proceeding to compile"

The POST /compile/check endpoint validates LaTeX syntax without consuming compilation quota. Use it as a fast pre-check in pull request pipelines to catch errors before spending quota on full compilation.

Async Compilation for Long Documents

For documents that take more than a few seconds to compile (theses, books with bibliography), async compilation with polling and webhooks avoids CI timeout issues:

bash
# Submit async job
JOB_ID=$(curl -s -X POST https://api.formatex.io/api/v1/compile/async \
  -H "X-API-Key: $FORMATEX_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"content\": $(cat thesis.tex | jq -Rs .), \"engine\": \"latexmk\"}" \
  | jq -r '.job_id')

# Poll until complete
while true; do
  STATUS=$(curl -s https://api.formatex.io/api/v1/jobs/$JOB_ID \
    -H "X-API-Key: $FORMATEX_KEY" | jq -r '.status')
  [ "$STATUS" = "completed" ] && break
  [ "$STATUS" = "failed" ] && { echo "Compilation failed"; exit 1; }
  sleep 2
done

# Download the PDF
curl -s https://api.formatex.io/api/v1/jobs/$JOB_ID/pdf \
  -H "X-API-Key: $FORMATEX_KEY" --output thesis.pdf

Async compilation is particularly useful for latexmk with bibliography processing, which requires multiple passes and can take 60–300 seconds. To understand when to choose latexmk over pdflatex or xelatex, see the complete guide to LaTeX engines.

Rate Limits and Plan Considerations

PlanCompilations/moTimeoutNotes
Free1530sTesting and small projects
Pro500120sMost CI use cases
Max2,000300sHigh-frequency pipelines
Enterprise15,000300sEnterprise / monorepo builds

Each CI run counts as one compilation. If your pipeline runs on every push across many branches, estimate your monthly usage and size your plan accordingly.

Use the dashboard to monitor usage and set up alerts before hitting your plan limit. If you hit rate limits frequently, review exponential backoff and retry logic for the LaTeX API to make your pipelines more resilient.

\end{article}

Back to blog

\related{posts}

One quick thing

We track anonymous usage — page views, feature usage, compilation events — to understand what works and what doesn't. No ads, no personal data, no third-party sharing.

Cookie policy