\begin{article}
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.

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:
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.pdfStore 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
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.pdfNo Docker, no cache, no TeX Live. The job runs in seconds.
Uploading to a Release
- name: Upload to release
uses: softprops/action-gh-release@v1
with:
files: docs/report.pdfCommitting the PDF back to the repo
- 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 pushAuto-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
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:
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.
{
"error": "compilation failed",
"log": "! Undefined control sequence.\nl.12 \\badcommand\n..."
}In shell, check the exit code and parse the log:
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.pdfSyntax 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:
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:
# 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.pdfAsync 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
| Plan | Compilations/mo | Timeout | Notes |
|---|---|---|---|
| Free | 15 | 30s | Testing and small projects |
| Pro | 500 | 120s | Most CI use cases |
| Max | 2,000 | 300s | High-frequency pipelines |
| Enterprise | 15,000 | 300s | Enterprise / 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.
Related Articles
- LaTeX Compilation Timeouts in CI/CD — Diagnose and fix timeout errors specific to CI pipelines, and choose the right plan timeout for your documents
- Why TeX Live Docker Images Are 4 GB — Understand why the traditional CI approach is so heavy and why an API call is lighter
- Async LaTeX Compilation and Webhooks — Deep dive into async jobs, polling patterns, and webhook callbacks for long-running compilations
- LaTeX API Rate Limiting and Retry Logic — Implement exponential backoff and request queuing to handle rate limits gracefully in CI
- LaTeX API Error Codes: Complete Guide — Full reference for every API error code you may encounter during automated compilation
\end{article}
\related{posts}




