Are you running Gotenberg or Puppeteer as Docker containers to generate PDFs? If you're struggling with Chromium's memory consumption, bloated container images, and scaling complexity, delegating PDF generation to an API is a better approach.
This guide shows you how to call the FUNBREW PDF API from Docker Compose and Kubernetes environments to generate PDFs, with practical code examples.
For the basics of the API itself, see the quickstart guide.
Challenges of Self-Hosted PDF Generation
Problems with Gotenberg / Puppeteer Containers
| Issue | Details |
|---|---|
| Image size | 1GB+ with Chromium. Slow to pull and deploy |
| Memory usage | Chromium processes consume 500MB-1GB. Common cause of OOMKill |
| Scaling | Rendering is CPU-intensive. Horizontal scaling requires significant resources |
| Maintenance | Chromium version management and security patching required |
| Font management | Adding CJK fonts and managing font caches is cumbersome |
Benefits of API-Based PDF Generation
| Benefit | Details |
|---|---|
| Lightweight containers | Just an HTTP client. Dramatically smaller image size |
| Low resources | Minimal CPU and memory consumption |
| No scaling needed | The API handles PDF generation scaling |
| Font support | Multilingual fonts including CJK are supported out of the box |
| Engine choice | Switch between quality (Chromium) and fast (wkhtmltopdf) per use case |
For more on engine differences and selection criteria, see the HTML to PDF conversion guide.
Docker Compose Integration
Basic Configuration
Here's a Docker Compose configuration for an application that uses the PDF API. Without a Chromium container, the setup is much simpler.
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- FUNBREW_API_KEY=${FUNBREW_API_KEY}
- FUNBREW_API_URL=https://api.pdf.funbrew.cloud
- NODE_ENV=production
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
worker:
build:
context: .
dockerfile: Dockerfile
command: ["node", "worker.js"]
environment:
- FUNBREW_API_KEY=${FUNBREW_API_KEY}
- FUNBREW_API_URL=https://api.pdf.funbrew.cloud
- REDIS_URL=redis://redis:6379
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
- redis_data:/data
volumes:
redis_data:
Lightweight Dockerfile
Since PDF generation is delegated to the API, your Dockerfile stays lightweight. No Chromium or font installation needed.
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
# No Chromium needed!
# RUN apt-get install -y chromium fonts-noto-cjk # Not needed anymore
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Compare this to a Gotenberg-based setup:
# Before: Using Gotenberg (image size: ~1.5GB)
services:
gotenberg:
image: gotenberg/gotenberg:8
ports:
- "3000:3000"
deploy:
resources:
limits:
memory: 2G
# After: Using FUNBREW PDF API (image size: ~150MB)
services:
app:
build: .
# No Chromium container needed
Application Code (Node.js)
// src/pdf-service.ts
import { FunbrewPdf } from '@funbrew/pdf';
const client = new FunbrewPdf({
apiKey: process.env.FUNBREW_API_KEY!,
});
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
const html = renderInvoiceTemplate(data);
const response = await client.generate({
html,
engine: 'quality',
format: 'A4',
});
return Buffer.from(response.data);
}
export async function generateReportPdf(markdown: string): Promise<Buffer> {
const response = await client.markdown({
markdown,
theme: 'github',
format: 'A4',
});
return Buffer.from(response.data);
}
For SDK usage details, see the language-specific quickstart. For concrete invoice implementation examples, see the invoice PDF automation guide.
Environment Variables
# .env (do not commit to Git)
FUNBREW_API_KEY=sk-your-api-key-here
# .env.example (commit to repository)
FUNBREW_API_KEY=sk-your-api-key-here-replace-me
For secure API key management, see the security guide.
Kubernetes (k8s) PDF Generation Service
Deployment
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pdf-service
labels:
app: pdf-service
spec:
replicas: 3
selector:
matchLabels:
app: pdf-service
template:
metadata:
labels:
app: pdf-service
spec:
containers:
- name: pdf-service
image: your-registry.com/pdf-service:latest
ports:
- containerPort: 3000
env:
- name: FUNBREW_API_KEY
valueFrom:
secretKeyRef:
name: funbrew-credentials
key: api-key
- name: NODE_ENV
value: "production"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
startupProbe:
httpGet:
path: /health
port: 3000
failureThreshold: 30
periodSeconds: 10
Notice how low the resource requirements are. Since PDF generation is delegated to the API, you need only 256Mi memory instead of the 2Gi+ required by Chromium containers.
Service & Ingress
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: pdf-service
spec:
selector:
app: pdf-service
ports:
- port: 80
targetPort: 3000
type: ClusterIP
---
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pdf-service-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
spec:
ingressClassName: nginx
rules:
- host: pdf.your-domain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pdf-service
port:
number: 80
tls:
- hosts:
- pdf.your-domain.com
secretName: pdf-service-tls
Secret Management
Kubernetes Secrets
# Create the API key as a Secret
kubectl create secret generic funbrew-credentials \
--from-literal=api-key=sk-your-api-key-here
External Secrets Operator (AWS / GCP / Azure)
For production environments, use the External Secrets Operator to sync with your cloud provider's secret store.
# k8s/external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: funbrew-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: funbrew-credentials
data:
- secretKey: api-key
remoteRef:
key: funbrew-pdf-api-key
Sealed Secrets (GitOps)
If you're using a GitOps workflow, Sealed Secrets lets you safely commit encrypted Secrets to your Git repository.
# Create a Sealed Secret
kubeseal --format=yaml \
--cert=pub-cert.pem \
< k8s/secret.yaml \
> k8s/sealed-secret.yaml
Horizontal Pod Autoscaler (HPA)
Automatically scale Pods based on PDF generation request load.
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: pdf-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: pdf-service
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 2
periodSeconds: 60
Since PDF generation is handled by the API, your application Pods maintain low CPU and memory usage. You can set conservative scaling thresholds.
Health Check Implementation
Application Health Endpoints
// src/health.ts
import express from 'express';
const app = express();
// Liveness: Is the app alive?
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Readiness: Can it accept requests?
app.get('/ready', async (req, res) => {
try {
// Check connectivity to FUNBREW PDF API
const response = await fetch(
`${process.env.FUNBREW_API_URL}/v1/health`,
{ signal: AbortSignal.timeout(5000) }
);
if (response.ok) {
res.status(200).json({ status: 'ready', pdfApi: 'connected' });
} else {
res.status(503).json({ status: 'not ready', pdfApi: 'unavailable' });
}
} catch (error) {
res.status(503).json({ status: 'not ready', pdfApi: 'unreachable' });
}
});
Avoiding Over-Dependency on External API Availability
When checking external API connectivity in Readiness Probes, be careful not to unnecessarily remove Pods due to transient network errors.
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3 # Only mark Not Ready after 3 consecutive failures
successThreshold: 1
CI/CD Pipeline (GitHub Actions)
PDF Generation Tests
Run PDF generation tests in your CI/CD pipeline.
# .github/workflows/pdf-test.yml
name: PDF Generation Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: PDF generation smoke test
env:
FUNBREW_API_KEY: ${{ secrets.FUNBREW_API_KEY }}
run: |
node -e "
const { FunbrewPdf } = require('@funbrew/pdf');
const client = new FunbrewPdf({ apiKey: process.env.FUNBREW_API_KEY });
async function test() {
const res = await client.generate({
html: '<h1>CI Test</h1><p>Generated at $(date -u)</p>',
engine: 'fast',
format: 'A4',
});
console.log('PDF generated:', res.data.length, 'bytes');
if (res.data.length < 100) throw new Error('PDF too small');
}
test().catch(e => { console.error(e); process.exit(1); });
"
Build & Deploy
# .github/workflows/deploy.yml
name: Build & Deploy
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-arn: arn:aws:iam::role/github-actions
aws-region: ap-northeast-1
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/pdf-service:$IMAGE_TAG .
docker build -t $REGISTRY/pdf-service:latest .
docker push $REGISTRY/pdf-service:$IMAGE_TAG
docker push $REGISTRY/pdf-service:latest
- name: Deploy to Kubernetes
run: |
kubectl set image deployment/pdf-service \
pdf-service=$REGISTRY/pdf-service:${{ github.sha }}
kubectl rollout status deployment/pdf-service --timeout=300s
Automated PDF Reports in GitHub Actions
Generate a PDF report for each PR and save it as an artifact.
# .github/workflows/report.yml
name: Generate PDF Report
on:
pull_request:
types: [opened, synchronize]
jobs:
generate-report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate changelog PDF
env:
FUNBREW_API_KEY: ${{ secrets.FUNBREW_API_KEY }}
run: |
# Generate changelog as Markdown
git log --oneline origin/main..HEAD > changelog.md
# Convert Markdown to PDF
curl -X POST https://api.pdf.funbrew.cloud/v1/pdf/from-markdown \
-H "Authorization: Bearer $FUNBREW_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"markdown\": \"$(cat changelog.md | jq -Rs .)\"}" \
--output report.pdf
- name: Upload PDF artifact
uses: actions/upload-artifact@v4
with:
name: pr-report
path: report.pdf
For more on Markdown to PDF conversion, see the Markdown to PDF API guide.
Python Application Example (FastAPI + Docker)
FastAPI PDF Endpoint
# app/main.py
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from funbrew_pdf import FunbrewPdf
import os
import io
app = FastAPI()
client = FunbrewPdf(api_key=os.environ["FUNBREW_API_KEY"])
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/generate-pdf")
async def generate_pdf(data: dict):
try:
response = client.generate(
html=data["html"],
engine=data.get("engine", "quality"),
format=data.get("format", "A4"),
)
return StreamingResponse(
io.BytesIO(response.data),
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=output.pdf"},
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/generate-report")
async def generate_report(data: dict):
try:
response = client.markdown(
markdown=data["markdown"],
theme=data.get("theme", "github"),
format="A4",
)
return StreamingResponse(
io.BytesIO(response.data),
media_type="application/pdf",
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
For more on PDF API usage with Python, see the Django & FastAPI guide.
Python Dockerfile
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
# No Chromium needed! Stays lightweight
EXPOSE 8000
USER nobody
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# requirements.txt
fastapi==0.115.0
uvicorn==0.32.0
funbrew-pdf==1.1.0
Migrating from Gotenberg
If you're using Gotenberg, migrating to FUNBREW PDF API is straightforward.
API Call Mapping
| Gotenberg | FUNBREW PDF API |
|---|---|
POST /forms/chromium/convert/html |
POST /v1/pdf/from-html |
POST /forms/chromium/convert/url |
POST /v1/pdf/from-url |
POST /forms/chromium/convert/markdown |
POST /v1/pdf/from-markdown |
waitTimeout |
Managed by API server |
paperWidth / paperHeight |
format: "A4" / format: "Letter" |
Migration Code Example
// Before: Gotenberg
async function generateWithGotenberg(html: string): Promise<Buffer> {
const formData = new FormData();
formData.append('files', new Blob([html], { type: 'text/html' }), 'index.html');
const response = await fetch('http://gotenberg:3000/forms/chromium/convert/html', {
method: 'POST',
body: formData,
});
return Buffer.from(await response.arrayBuffer());
}
// After: FUNBREW PDF API
import { FunbrewPdf } from '@funbrew/pdf';
const client = new FunbrewPdf({ apiKey: process.env.FUNBREW_API_KEY! });
async function generateWithFunbrew(html: string): Promise<Buffer> {
const response = await client.generate({
html,
engine: 'quality',
format: 'A4',
});
return Buffer.from(response.data);
}
You can remove the Gotenberg service from your Docker Compose, significantly simplifying your infrastructure. For a comparison of self-hosted vs API approaches, see the PDF API comparison guide.
Batch PDF Generation
Use Kubernetes Jobs to generate PDFs in parallel batches.
# k8s/batch-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: pdf-batch-generation
spec:
parallelism: 5
completions: 100
completionMode: Indexed
template:
spec:
containers:
- name: pdf-generator
image: your-registry.com/pdf-batch:latest
env:
- name: FUNBREW_API_KEY
valueFrom:
secretKeyRef:
name: funbrew-credentials
key: api-key
- name: JOB_COMPLETION_INDEX
valueFrom:
fieldRef:
fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
resources:
requests:
cpu: "100m"
memory: "128Mi"
restartPolicy: OnFailure
backoffLimit: 3
// batch/generate.ts
import { FunbrewPdf } from '@funbrew/pdf';
const client = new FunbrewPdf({ apiKey: process.env.FUNBREW_API_KEY! });
const batchIndex = parseInt(process.env.JOB_COMPLETION_INDEX || '0');
async function processBatch() {
// Fetch batch records from database
const records = await getRecordsBatch(batchIndex, 100);
for (const record of records) {
const html = renderTemplate(record);
const response = await client.generate({
html,
engine: 'fast', // fast engine is ideal for batch processing
format: 'A4',
});
await uploadToStorage(record.id, response.data);
console.log(`Generated PDF for record ${record.id}`);
}
}
processBatch().catch(console.error);
For more batch processing patterns, see the batch processing guide.
Production Best Practices
Retry and Circuit Breaker
For containers calling external APIs, implementing retry logic and a circuit breaker is essential.
// src/resilient-client.ts
import { FunbrewPdf } from '@funbrew/pdf';
class ResilientPdfClient {
private client: FunbrewPdf;
private failureCount = 0;
private lastFailure = 0;
private readonly threshold = 5;
private readonly resetTimeout = 60000; // 60 seconds
constructor() {
this.client = new FunbrewPdf({
apiKey: process.env.FUNBREW_API_KEY!,
});
}
async generate(options: any, retries = 3): Promise<Buffer> {
// Circuit breaker check
if (this.isCircuitOpen()) {
throw new Error('Circuit breaker is open. PDF API may be unavailable.');
}
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await this.client.generate(options);
this.onSuccess();
return Buffer.from(response.data);
} catch (error: any) {
this.onFailure();
// Don't retry on 4xx errors
if (error.status >= 400 && error.status < 500) {
throw error;
}
if (attempt === retries) throw error;
// Exponential backoff
await new Promise(r =>
setTimeout(r, 1000 * Math.pow(2, attempt - 1))
);
}
}
throw new Error('Unreachable');
}
private isCircuitOpen(): boolean {
if (this.failureCount >= this.threshold) {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.failureCount = 0;
return false;
}
return true;
}
return false;
}
private onSuccess() { this.failureCount = 0; }
private onFailure() {
this.failureCount++;
this.lastFailure = Date.now();
}
}
export const pdfClient = new ResilientPdfClient();
For more error handling patterns, see the error handling guide.
Monitoring (Prometheus + Grafana)
Expose PDF generation metrics for Prometheus collection.
// src/metrics.ts
import { Counter, Histogram, register } from 'prom-client';
export const pdfGenerationCounter = new Counter({
name: 'pdf_generation_total',
help: 'Total number of PDF generation requests',
labelNames: ['status', 'engine'],
});
export const pdfGenerationDuration = new Histogram({
name: 'pdf_generation_duration_seconds',
help: 'PDF generation duration in seconds',
labelNames: ['engine'],
buckets: [0.5, 1, 2, 5, 10, 30],
});
// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
NetworkPolicy
Restrict egress traffic to only allow communication with the PDF API.
# k8s/network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: pdf-service-policy
spec:
podSelector:
matchLabels:
app: pdf-service
policyTypes:
- Egress
egress:
- to: [] # Allow outbound to external API (FUNBREW PDF)
ports:
- port: 443
protocol: TCP
- to:
- podSelector:
matchLabels:
app: redis
ports:
- port: 6379
protocol: TCP
Troubleshooting
Common Issues and Solutions
| Issue | Cause | Solution |
|---|---|---|
| Container OOMKill | Memory limit too low | Increase resources.limits.memory |
| DNS resolution errors | Cluster DNS issues | Check CoreDNS status |
| API timeout | NetworkPolicy blocking HTTPS | Allow egress on port 443 |
| Secret not found | Secret name mismatch | Verify with kubectl get secrets |
| Image pull failure | Registry auth error | Configure imagePullSecrets |
For more on production operations, see the production guide.
Debug Commands
# Check Pod status
kubectl get pods -l app=pdf-service
# View logs
kubectl logs -l app=pdf-service --tail=100
# Test API connectivity from inside a Pod
kubectl exec -it deploy/pdf-service -- \
curl -s https://api.pdf.funbrew.cloud/v1/health
# Verify Secret
kubectl get secret funbrew-credentials -o jsonpath='{.data.api-key}' | base64 -d
# Check HPA status
kubectl get hpa pdf-service-hpa
Using with Next.js / Nuxt
FUNBREW PDF API works the same way in containerized Next.js and Nuxt applications. For frontend framework integration, see the Next.js & Nuxt guide.
Summary
PDF generation in Docker and Kubernetes environments is dramatically simplified by delegating to an API.
- Docker Compose: Eliminate Gotenberg/Puppeteer containers. Image size reduced by 10x
- Kubernetes: Low-resource Deployments with HPA for autoscaling
- Secret management: External Secrets Operator for cloud KMS integration
- CI/CD: PDF generation tests and automated reports in GitHub Actions
- Batch processing: Parallel batch generation with k8s Jobs
For webhook integration to enhance async processing, see the webhook integration guide. For a comparison with serverless, see the AWS Lambda PDF generation guide.
Get started by creating a free account and trying the API in the Playground.