7 min read

Build Custom n8n Nodes & Deploy with Kamal


đź’ˇ

Build custom n8n nodes when you need functionality that doesn’t exist natively. This tutorial walks through creating a random number generator node and deploying it to production with Kamal. Get the starter repo on GitHub.

Why build custom n8n nodes?

Some functionality isn’t supported natively in n8n. For example, generating random numbers is something I need often:

  • YouTube automation: Generate a random number, use a switch to pick a video topic, and have AI create content that uploads automatically
  • Randomizing API results: When an API returns 5 items but you want to process them in random order
  • A/B testing: Randomly route workflows to different branches

A random number generator could be a simple code node, but packaging it as a custom node makes it reusable and shareable.

What the random number node does

The node has multiple operations:

OperationDescription
Random IntegerGenerate a single random integer (min/max inclusive)
Random FloatGenerate a decimal number with configurable precision
Multiple Random NumbersGenerate an array of random numbers

Parameters

  • Minimum: Lower bound of the range
  • Maximum: Upper bound of the range
  • Count: Number of values to generate (multiple mode only)
  • Decimal Places: Precision for float values
  • Unique Numbers: Ensure no duplicates (multiple mode only)
  • Output Field Name: JSON field name for the result (default: randomNumber)

For example, selecting “multiple random” with min 0, max 100, and count 5 gives you 5 unique random numbers. Add a split node and you can loop through them individually.

Prerequisites

  • Node.js 18+
  • pnpm 9.1+
  • Docker (for building and testing)

Setup

# Install pnpm if not installed
corepack enable && corepack prepare [email protected] --activate

# Install dependencies
pnpm install

# Build the node
pnpm build

Local development

pnpm start

This runs both TypeScript compiler (watch mode) and n8n container concurrently. When you edit code:

  1. TypeScript auto-compiles to dist/
  2. Docker detects changes and restarts n8n automatically

Access n8n at http://localhost:5678

Test production image

pnpm docker:test

Builds the Dockerfile and runs it locally - same image that Kamal will deploy.

Project structure

├── nodes/RandomGenerator/     # Custom node source
├── credentials/               # Credential definitions (if needed)
├── dist/                      # Compiled output (generated)
├── .kamal/                    # Kamal hooks and secrets
├── config/                    # Kamal deploy configuration
├── Dockerfile                 # Production image build
├── docker-compose.yml         # Local development
├── gulpfile.js                # Build script
├── package.json
└── tsconfig.json

Node anatomy

Every n8n node is a TypeScript class that implements INodeType. Here’s the actual Random Generator node:

import {
    IExecuteFunctions,
    INodeExecutionData,
    INodeType,
    INodeTypeDescription,
    NodeOperationError,
} from 'n8n-workflow';

// Helper functions (outside class because execute() rebinds 'this')
function randomInt(min: number, max: number): number {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

function randomFloat(min: number, max: number, decimalPlaces: number): number {
    const value = Math.random() * (max - min) + min;
    return Number(value.toFixed(decimalPlaces));
}

function multipleRandomInt(min: number, max: number, count: number, unique: boolean): number[] {
    const results: number[] = [];

    if (unique) {
        const available = Array.from({ length: max - min + 1 }, (_, i) => min + i);
        for (let i = 0; i < count; i++) {
            const randomIndex = Math.floor(Math.random() * available.length);
            results.push(available[randomIndex]);
            available.splice(randomIndex, 1);
        }
    } else {
        for (let i = 0; i < count; i++) {
            results.push(randomInt(min, max));
        }
    }

    return results;
}

export class RandomGenerator implements INodeType {
    description: INodeTypeDescription = {
        displayName: 'Random Number Generator',
        name: 'randomGenerator',
        icon: 'fa:dice',
        group: ['transform'],
        version: 1,
        subtitle: '={{$parameter["operation"]}}',
        description: 'Generate random numbers within a specified range',
        defaults: {
            name: 'Random Generator',
        },
        inputs: ['main'],
        outputs: ['main'],
        properties: [
            {
                displayName: 'Operation',
                name: 'operation',
                type: 'options',
                noDataExpression: true,
                options: [
                    {
                        name: 'Random Integer',
                        value: 'randomInt',
                        description: 'Generate a random integer within a range',
                        action: 'Generate a random integer within a range',
                    },
                    {
                        name: 'Random Float',
                        value: 'randomFloat',
                        description: 'Generate a random floating-point number within a range',
                        action: 'Generate a random floating point number within a range',
                    },
                    {
                        name: 'Multiple Random Numbers',
                        value: 'multipleRandom',
                        description: 'Generate multiple random numbers within a range',
                        action: 'Generate multiple random numbers within a range',
                    },
                ],
                default: 'randomInt',
            },
            {
                displayName: 'Minimum',
                name: 'min',
                type: 'number',
                default: 0,
                description: 'Minimum value (inclusive)',
                required: true,
            },
            {
                displayName: 'Maximum',
                name: 'max',
                type: 'number',
                default: 100,
                description: 'Maximum value (inclusive for integers, exclusive for floats)',
                required: true,
            },
            {
                displayName: 'Count',
                name: 'count',
                type: 'number',
                default: 5,
                description: 'How many random numbers to generate',
                displayOptions: {
                    show: {
                        operation: ['multipleRandom'],
                    },
                },
            },
            {
                displayName: 'Decimal Places',
                name: 'decimalPlaces',
                type: 'number',
                default: 2,
                description: 'Number of decimal places for float values',
                displayOptions: {
                    show: {
                        operation: ['randomFloat'],
                    },
                },
            },
            {
                displayName: 'Output Field Name',
                name: 'outputField',
                type: 'string',
                default: 'randomNumber',
                description: 'Name of the field to store the result',
            },
            {
                displayName: 'Unique Numbers',
                name: 'unique',
                type: 'boolean',
                default: false,
                description: 'Whether to ensure all generated numbers are unique (only for multiple random)',
                displayOptions: {
                    show: {
                        operation: ['multipleRandom'],
                    },
                },
            },
        ],
    };

    async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
        const items = this.getInputData();
        const returnData: INodeExecutionData[] = [];

        for (let i = 0; i < items.length; i++) {
            try {
                const operation = this.getNodeParameter('operation', i) as string;
                const min = this.getNodeParameter('min', i) as number;
                const max = this.getNodeParameter('max', i) as number;
                const outputField = this.getNodeParameter('outputField', i) as string;

                if (min > max) {
                    throw new NodeOperationError(
                        this.getNode(),
                        'Minimum value cannot be greater than maximum value',
                        { itemIndex: i },
                    );
                }

                let result: number | number[];

                switch (operation) {
                    case 'randomInt':
                        result = randomInt(min, max);
                        break;

                    case 'randomFloat':
                        const decimalPlaces = this.getNodeParameter('decimalPlaces', i) as number;
                        result = randomFloat(min, max, decimalPlaces);
                        break;

                    case 'multipleRandom':
                        const count = this.getNodeParameter('count', i) as number;
                        const unique = this.getNodeParameter('unique', i) as boolean;

                        if (unique && count > (max - min + 1)) {
                            throw new NodeOperationError(
                                this.getNode(),
                                `Cannot generate ${count} unique numbers in range ${min}-${max}. Range only contains ${max - min + 1} possible values.`,
                                { itemIndex: i },
                            );
                        }

                        result = multipleRandomInt(min, max, count, unique);
                        break;

                    default:
                        throw new NodeOperationError(
                            this.getNode(),
                            `Unknown operation: ${operation}`,
                            { itemIndex: i },
                        );
                }

                const newItem: INodeExecutionData = {
                    json: {
                        ...items[i].json,
                        [outputField]: result,
                    },
                    pairedItem: { item: i },
                };

                returnData.push(newItem);
            } catch (error) {
                if (this.continueOnFail()) {
                    returnData.push({
                        json: {
                            ...items[i].json,
                            error: (error as Error).message,
                        },
                        pairedItem: { item: i },
                    });
                    continue;
                }
                throw error;
            }
        }

        return [returnData];
    }
}

Key concepts

  • description: Defines how the node appears in the UI (name, icon, inputs, outputs)
  • properties: The parameters users can configure
  • displayOptions: Show/hide properties based on other selections (e.g., “Count” only shows for multipleRandom)
  • execute: The async function that runs when the node executes
  • NodeOperationError: Proper error handling with item index for debugging
  • continueOnFail(): Graceful error handling when users enable “Continue on Fail”
  • pairedItem: Links output items to input items for debugging

To explore available options, Ctrl+click on INodeTypeDescription in your IDE to see the TypeScript interface.

Adding custom credentials

You can also create custom credential types. In the credentials/ folder:

import { ICredentialType, INodeProperties } from "n8n-workflow";

export class ExampleApiCredentials implements ICredentialType {
  name = "exampleApi";
  displayName = "Example API";
  properties: INodeProperties[] = [
    {
      displayName: "API Key",
      name: "apiKey",
      type: "string",
      typeOptions: { password: true },
      default: "",
    },
  ];
}

After compiling, these appear in n8n’s credentials manager.

Deploy with Kamal

Kamal handles production deployment without Kubernetes complexity:

  1. Builds your Docker image locally
  2. Pushes to your container registry
  3. SSHs into your server
  4. Pulls and runs the new container
  5. Routes traffic through a proxy for zero-downtime deploys

1. Set environment variables

Only needed if you uncomment the Postgres setup:

export KAMAL_REGISTRY_PASSWORD=your_dockerhub_token
export POSTGRES_USER=n8n
export POSTGRES_PASSWORD=your_secure_password

2. First-time setup

# Setup server and deploy accessories (database)
kamal setup

This installs Docker on your server, pulls the image, and starts everything with a reverse proxy.

3. Deploy

# Build image, push to registry, and deploy
kamal deploy

Kamal handles the traffic switchover gracefully - new container starts, proxy routes to it, old container stops.

4. Useful commands

# View logs
kamal logs

# Open shell in container
kamal shell

# Redeploy after changes
kamal deploy

# Deploy only the app (skip accessories)
kamal app boot

Adding more nodes

  1. Create a new folder in nodes/ with your node name
  2. Add YourNode.node.ts following the RandomGenerator pattern
  3. Update package.json to include the new node path in n8n.nodes
  4. Rebuild: pnpm build

When to build custom nodes

Custom nodes are powerful but not always necessary. Consider them when:

  • You have a tool/API you want to integrate deeply with n8n
  • You need reusable functionality across many workflows
  • You want to share nodes with the n8n community
  • The code node feels too repetitive for your use case

For one-off logic, the built-in code node is usually sufficient.

Get the code

The starter repo includes everything you need: Docker setup, TypeScript config, build scripts, and example nodes.

GitHub repo: github.com/letItCurl/n8n-custom-node

Clone it, modify the nodes, and deploy to your own infrastructure. If you’re using Claude Code or similar AI tools, the base repo provides good context for generating new node code.

đź“§