Automating PDF Report Generation with n8n and Node.js
What You’ll Need
- n8n Cloud or self-hosted n8n
- Hetzner VPS or Contabo VPS for hosting
- Namecheap if domain needed
- Node.js 16+ installed locally
- A PDF generation library (we’ll use PDFKit)
- Basic knowledge of n8n workflows
Table of Contents
- The PDF Generation Challenge
- Setting Up Your n8n Workflow
- Building a Node.js PDF Service
- Connecting n8n to Your PDF Generator
- Real-World Example: Sales Report Automation
- Getting Started
The PDF Generation Challenge
I’ve been automating workflows for three years now, and PDF generation is one of the most common requests I get. The challenge isn’t just creating a PDF—it’s doing it reliably, at scale, without manual intervention.
Most no-code platforms struggle with dynamic PDF creation because they lack granular control over styling, layout, and data injection. That’s where combining n8n Cloud with a custom Node.js service becomes powerful. You get the orchestration power of n8n with the flexibility of server-side rendering.
Before we dive in, I should mention that if you’re deciding between a cloud solution and self-hosting, the considerations differ when you’re generating files. I’ve written about self-hosted n8n vs n8n Cloud before—self-hosted gives you full control over file storage and processing power, which matters for high-volume PDF generation.
Setting Up Your n8n Workflow
First, let’s create the orchestration layer in n8n. I’m using n8n Cloud for this example, but the workflow translates directly to self-hosted instances.
Create a new workflow and start with a Webhook trigger:
{
"nodes": [
{
"parameters": {
"path": "generate-report",
"httpMethod": "POST",
"responseMode": "lastNode",
"options": {}
},
"name": "Report Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300]
}
]
}
This webhook will accept POST requests with your report data. Next, add a function node to parse and validate incoming data:
const data = $input.all();
const reportRequest = data[0].json.body;
if (!reportRequest.companyName || !reportRequest.salesData || !reportRequest.period) {
throw new Error('Missing required fields: companyName, salesData, period');
}
return [
{
json: {
companyName: reportRequest.companyName,
salesData: reportRequest.salesData,
period: reportRequest.period,
generatedAt: new Date().toISOString(),
filename: `Report_${reportRequest.companyName}_${reportRequest.period}.pdf`
}
}
];
Now add an HTTP request node that will call your PDF generation service:
{
"parameters": {
"url": "http://your-pdf-service.local:3000/generate-pdf",
"method": "POST",
"headerParametersUi": {
"parameter": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"bodyParametersJson": "={{ $node[\"Parse Report Data\"].json }}"
},
"name": "Call PDF Generator",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [450, 300]
}
💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.
Building a Node.js PDF Service
Your Node.js service is where the actual PDF magic happens. I’m using Express and PDFKit for this—it gives you pixel-perfect control over layouts.
Create a new Node.js project:
mkdir pdf-generator-service
cd pdf-generator-service
npm init -y
npm install express pdfkit axios dotenv cors
Create your main server file:
const express = require('express');
const PDFDocument = require('pdfkit');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(express.json());
app.use(cors());
const PDF_OUTPUT_DIR = path.join(__dirname, 'generated-pdfs');
if (!fs.existsSync(PDF_OUTPUT_DIR)) {
fs.mkdirSync(PDF_OUTPUT_DIR, { recursive: true });
}
app.post('/generate-pdf', async (req, res) => {
try {
const { companyName, salesData, period, filename } = req.body;
const pdfPath = path.join(PDF_OUTPUT_DIR, filename);
const doc = new PDFDocument({
size: 'A4',
margin: 50,
info: {
Title: `Sales Report - ${period}`,
Author: companyName,
Subject: 'Automated Sales Report'
}
});
const writeStream = fs.createWriteStream(pdfPath);
doc.pipe(writeStream);
doc.fontSize(24).font('Helvetica-Bold').text('SALES REPORT', { align: 'center' });
doc.moveDown();
doc.fontSize(12).font('Helvetica').text(`Company: ${companyName}`, { align: 'center' });
doc.fontSize(12).text(`Period: ${period}`, { align: 'center' });
doc.fontSize(10).text(`Generated: ${new Date().toLocaleString()}`, { align: 'center' });
doc.moveTo(50, doc.y).lineTo(545, doc.y).stroke();
doc.moveDown(1);
doc.fontSize(14).font('Helvetica-Bold').text('Sales Summary', { underline: true });
doc.moveDown(0.5);
let totalRevenue = 0;
let totalUnits = 0;
if (Array.isArray(salesData) && salesData.length > 0) {
const tableTop = doc.y + 20;
const col1X = 70;
const col2X = 250;
const col3X = 400;
const rowHeight = 25;
doc.fontSize(11).font('Helvetica-Bold');
doc.text('Product', col1X, tableTop);
doc.text('Units Sold', col2X, tableTop);
doc.text('Revenue', col3X, tableTop);
doc.moveTo(50, tableTop + 15).lineTo(545, tableTop + 15).stroke();
doc.font('Helvetica').fontSize(10);
salesData.forEach((item, index) => {
const rowY = tableTop + 20 + index * rowHeight;
doc.text(item.product || 'N/A', col1X, rowY);
doc.text(item.units || '0', col2X, rowY);
doc.text(`$${item.revenue || 0}`, col3X, rowY);
totalRevenue += item.revenue || 0;
totalUnits += item.units || 0;
});
const summaryY = tableTop + 20 + salesData.length * rowHeight + 20;
doc.moveTo(50, summaryY).lineTo(545, summaryY).stroke();
doc.fontSize(11).font('Helvetica-Bold');
doc.text('TOTALS', col1X, summaryY + 10);
doc.text(totalUnits.toString(), col2X, summaryY + 10);
doc.text(`$${totalRevenue.toFixed(2)}`, col3X, summaryY + 10);
}
doc.moveDown(3);
doc.fontSize(10).font('Helvetica').text('This is an automatically generated report. No signature required.', {
align: 'center',
color: '#999999'
});
doc.end();
writeStream.on('finish', () => {
const fileBuffer = fs.readFileSync(pdfPath);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', fileBuffer.length);
res.send(fileBuffer);
fs.unlink(pdfPath, (err) => {
if (err) console.error('Error deleting temp file:', err);
});
});
writeStream.on('error', (err) => {
console.error('Stream error:', err);
res.status(500).json({ error: 'PDF generation failed', details: err.message });
});
} catch (error) {
console.error('PDF generation error:', error);
res.status(500).json({ error: 'PDF generation failed', details: error.message });
}
});
app.get('/health', (req, res) => {
res.json({ status: 'PDF service running', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`PDF generation service listening on port ${PORT}`);
});
Create a .env file:
PORT=3000
NODE_ENV=development
Start the service:
node index.js
You should see: PDF generation service listening on port 3000
Connecting n8n to Your PDF Generator
Now let’s complete the n8n workflow to handle the response and store your PDFs.
In your n8n workflow, add a node to save the PDF file. Add a Set node after your HTTP request:
const pdfBuffer = $input.all()[0].binary.data;
const filename = $input.all()[0].json.filename || 'report.pdf';
return [
{
json: {
filename: filename,
size: pdfBuffer.length,
mimeType: 'application/pdf'
},
binary: {
data: pdfBuffer
}
}
];
Next, add a Write Binary File node to persist the PDF:
{
"parameters": {
"fileName": "={{ $node[\"Set\"].json.filename }}",
"dataPropertyName": "data",
"options": {
"outputFormat": "saveAs"
}
},
"name": "Save PDF File",
"type": "n8n-nodes-base.writeBinaryFile",
"typeVersion": 1,
"position": [650, 300]
}
If you’re using self-hosted n8n, you might want to check out my guide on setting up Nginx as a reverse proxy with SSL so your webhook endpoint is secure when receiving sensitive report data.
Finally, add a response node to confirm success:
{
"parameters": {
"responseCode": 200,
"options": {}
},
"name": "Return Success",
"type": "n8n-
Want to automate this yourself?
Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.
📬 Get Weekly Automation Tips
One email per week with tutorials, tools, and workflows. No spam, unsubscribe anytime.