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:
| Operation | Description |
|---|---|
| Random Integer | Generate a single random integer (min/max inclusive) |
| Random Float | Generate a decimal number with configurable precision |
| Multiple Random Numbers | Generate 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:
- TypeScript auto-compiles to
dist/ - 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 configuredisplayOptions: Show/hide properties based on other selections (e.g., “Count” only shows for multipleRandom)execute: The async function that runs when the node executesNodeOperationError: Proper error handling with item index for debuggingcontinueOnFail(): 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:
- Builds your Docker image locally
- Pushes to your container registry
- SSHs into your server
- Pulls and runs the new container
- 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
- Create a new folder in
nodes/with your node name - Add
YourNode.node.tsfollowing the RandomGenerator pattern - Update
package.jsonto include the new node path inn8n.nodes - 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.
Related Posts
n8n Docker Export & Import: Copy Workflows and Credentials Between Containers
5-minute tutorial on exporting and importing n8n workflows and credentials using Docker. Learn docker-compose setup, n8n export/import CLI commands, docker cp, and encryption key handling.
Ship and Monetize an n8n Community Node
A step‑by‑step guide for building, hardening, versioning, submitting, and monetizing an n8n community node. Architecture, rate‑limit hygiene, testing, and revenue.
30 Best n8n Community Nodes (2025)
Practical 2025 guide to the 30 best n8n community nodes—by sales, support, devops, and AI—with setup gotchas, rate‑limit tips, and paste‑in workflow ideas.