Automating PDF Report Generation with n8n and Node.js

Automating PDF Report Generation with n8n and Node.js

What You’ll Need

Table of Contents

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-nodes-base.respondToWebhook",
  "typeVersion": 1,
  "position": [850, 300]
}

This completes your basic workflow. The flow now looks like: Webhook → Parse Data → Call PDF Generator → Save File → Return Success.

Real-World Example: Sales Report Automation

Let me show you how this works end-to-end. You can trigger this workflow from anywhere—a form submission, a database trigger, or even a scheduled cron job.

Send a POST request to your n8n webhook:

curl -X POST http://your-n8n-instance.com/webhook/generate-report \
  -H "Content-Type: application/json" \
  -d '{
    "companyName": "TechCorp Industries",
    "period": "Q4-2024",
    "salesData": [
      {
        "product": "Enterprise License",
        "units": 45,
        "revenue": 22500
      },
      {
        "product": "Professional Plan",
        "units": 120,
        "revenue": 14400
      },
      {
        "product": "Support Package",
        "units": 85,
        "revenue": 8500
      }
    ]
  }'

Your workflow executes immediately. The Node.js service generates a beautifully formatted PDF with company branding, totals calculations, and timestamps. Within seconds, the PDF is saved to your configured storage location, and your webhook responds with confirmation.

This is genuinely powerful when you’re generating dozens of reports daily. No manual PDF creation, no formatting inconsistencies, no bottlenecks.

Getting Started

Ready to build this? Here’s what you need:

  1. Sign up for n8n Cloud or deploy a self-hosted instance on Hetzner VPS or DigitalOcean
  2. Create your Node.js service following the code above—run it on the same VPS or a lightweight Contabo VPS instance
  3. Connect your webhook and test with real data
  4. Store your PDFs locally, in cloud storage, or email them directly via n8n’s email nodes

The entire setup takes about 30 minutes. The time you’ll save running reports manually? That’s the real win.

Outsource Your Automation

Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr — mention SYS3-HUGO for priority. Or DM at chasebot.online .

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

Want this engine running on your own VPS?

This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.

Get System 3
system online