Building a Production IoT Gateway with Raspberry Pi and Node.js
A Raspberry Pi gateway is one of the most powerful architectural decisions you can make in an IoT deployment. It solves three hard problems simultaneously: protocol translation (BLE/Zigbee/Modbus sensors can't speak MQTT natively), offline resilience (data survives cloud outages), and bandwidth reduction (aggregate and compress locally, send summaries upstream).
This guide walks through a production-grade implementation used in smart building and agricultural deployments.
Gateway Architecture Overview
[BLE Sensors] [Zigbee Sensors] [Modbus PLCs]
↓ ↓ ↓
noble (BLE) zigbee2mqtt modbus-serial
↓ ↓ ↓
┌─────────────────────────────────────┐
│ Node.js Gateway Process │
│ ┌───────────┐ ┌────────────────┐ │
│ │ Ingestor │→ │ Local SQLite │ │
│ │ (multi- │ │ Buffer │ │
│ │ protocol) │ └────────┬───────┘ │
│ └───────────┘ │ │
│ ┌─────▼──────┐ │
│ │ Forwarder │ │
│ │ (MQTT TLS) │ │
│ └─────┬──────┘ │
└──────────────────────────┼──────────┘
↓
AWS IoT Core / Cloud
The SQLite buffer is the key reliability mechanism. Data is written locally first, then forwarded to the cloud. If connectivity drops, the ingestor keeps writing to SQLite. When connectivity restores, the forwarder drains the buffer in order.
Node.js Gateway Core
// gateway.js — production IoT gateway core
const mqtt = require('mqtt')
const noble = require('@abandonware/noble')
const Database = require('better-sqlite3')
const fs = require('fs')const CONFIG = {
awsEndpoint: process.env.AWS_IOT_ENDPOINT,
gatewayId: process.env.GATEWAY_ID || require('os').hostname(),
bufferPath: '/var/lib/iot-gateway/buffer.db',
certPath: '/etc/iot-gateway/certs',
maxBufferRows: 50000,
forwardBatchSize: 100,
forwardIntervalMs: 2000,
}
// ── Local SQLite buffer ──────────────────────────────────────
const db = new Database(CONFIG.bufferPath)
db.exec(`
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
payload TEXT NOT NULL,
ts INTEGER NOT NULL DEFAULT (unixepoch('now', 'subsec') * 1000),
forwarded INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_forwarded ON readings(forwarded, id);
`)
const insertReading = db.prepare(
'INSERT INTO readings (device_id, payload) VALUES (?, ?)'
)
const getPending = db.prepare(
'SELECT id, device_id, payload, ts FROM readings WHERE forwarded = 0 ORDER BY id LIMIT ?'
)
const markForwarded = db.prepare(
'UPDATE readings SET forwarded = 1 WHERE id IN (SELECT value FROM json_each(?))'
)
const pruneForwarded = db.prepare(
'DELETE FROM readings WHERE forwarded = 1 AND id < (SELECT MAX(id) - 10000 FROM readings WHERE forwarded = 1)'
)
function bufferReading(deviceId, data) {
const count = db.prepare('SELECT COUNT(*) as c FROM readings WHERE forwarded = 0').get().c
if (count >= CONFIG.maxBufferRows) {
console.warn([buffer] Full (${count} rows), dropping oldest unforwarded)
db.prepare('DELETE FROM readings WHERE id = (SELECT MIN(id) FROM readings WHERE forwarded = 0)').run()
}
insertReading.run(deviceId, JSON.stringify(data))
}
// ── AWS IoT Core MQTT client ─────────────────────────────────
let cloudConnected = false
const cloudClient = mqtt.connect(mqtts://${CONFIG.awsEndpoint}:8883, {
cert: fs.readFileSync(${CONFIG.certPath}/device.crt),
key: fs.readFileSync(${CONFIG.certPath}/device.key),
ca: fs.readFileSync(${CONFIG.certPath}/AmazonRootCA1.pem),
clientId: CONFIG.gatewayId,
reconnectPeriod: 5000,
keepalive: 30,
})
cloudClient.on('connect', () => {
cloudConnected = true
console.log('[cloud] Connected to AWS IoT Core')
})
cloudClient.on('offline', () => {
cloudConnected = false
console.warn('[cloud] Disconnected — buffering locally')
})
// ── Forwarder: drain buffer to cloud ────────────────────────
function forwardBatch() {
if (!cloudConnected) return
const rows = getPending.all(CONFIG.forwardBatchSize)
if (!rows.length) return
let published = 0
const ids = []
for (const row of rows) {
const topic = gateways/${CONFIG.gatewayId}/devices/${row.device_id}/telemetry
const payload = JSON.stringify({ ...JSON.parse(row.payload), ts: row.ts, gatewayId: CONFIG.gatewayId })
cloudClient.publish(topic, payload, { qos: 1 }, (err) => {
if (!err) published++
})
ids.push(row.id)
}
markForwarded.run(JSON.stringify(ids))
if (published > 0) pruneForwarded.run()
}
setInterval(forwardBatch, CONFIG.forwardIntervalMs)
BLE Protocol Bridge
// ble-ingestor.js — reads BLE advertisements from Nordic sensors
const noble = require('@abandonware/noble')const KNOWN_DEVICES = {
'aa:bb:cc:dd:ee:ff': { name: 'soil-sensor-01', type: 'soil_moisture' },
'aa:bb:cc:dd:ee:fe': { name: 'temp-sensor-01', type: 'temperature' },
}
noble.on('stateChange', (state) => {
if (state === 'poweredOn') noble.startScanning([], true) // allow duplicates
})
noble.on('discover', (peripheral) => {
const addr = peripheral.address
const device = KNOWN_DEVICES[addr]
if (!device) return
const mfr = peripheral.advertisement.manufacturerData
if (!mfr || mfr.length < 6) return
// Parse custom manufacturer payload (device-specific)
const value = mfr.readInt16LE(2) / 100
const battery = mfr.readUInt8(4)
const rssi = peripheral.rssi
bufferReading(device.name, {
type: device.type,
value,
battery,
rssi,
unit: device.type === 'soil_moisture' ? '%' : '°C',
})
})
Watchdog and Systemd Service
A gateway process that silently exits is worse than one that crashes loudly. Use Node.js's built-in watchdog pattern and systemd's Restart=always to guarantee automatic recovery.
/etc/systemd/system/iot-gateway.service
[Unit]
Description=IoT Gateway
After=network-online.target
Wants=network-online.target[Service]
Type=simple
User=iot
WorkingDirectory=/opt/iot-gateway
ExecStart=/usr/bin/node /opt/iot-gateway/gateway.js
Restart=always
RestartSec=5
WatchdogSec=60
NotifyAccess=main
Environment=NODE_ENV=production
Environment=AWS_IOT_ENDPOINT=your-endpoint.iot.us-east-1.amazonaws.com
Environment=GATEWAY_ID=gateway-building-a-floor-2
StandardOutput=journal
StandardError=journal
SyslogIdentifier=iot-gateway
[Install]
WantedBy=multi-user.target
The WatchdogSec=60 setting requires the process to call sd_notify(WATCHDOG=1) at least once per minute. Add this to the Node.js side:
// Systemd watchdog notification
if (process.env.WATCHDOG_USEC) {
const intervalMs = parseInt(process.env.WATCHDOG_USEC) / 2000 // half the timeout
const sd = require('@twooster/sd-notify')
setInterval(() => sd.watchdog(), intervalMs)
}
Offline Buffering Strategy
The SQLite buffer provides durability guarantees that in-memory queues cannot. Key design decisions:
db.pragma('journal_mode = WAL') — concurrent reads don't block writesdb.pragma('synchronous = NORMAL') — safe against OS crashes, fast enough for sensor ratesFor a deeper look at how this data lands in cloud storage, see [IoT Data Pipeline: Sensor to Dashboard](/blog/iot-data-pipeline-sensor-to-dashboard).
The gateway pattern also fits naturally within the [Edge-Cloud Hybrid architecture](/blog/edge-computing-iot-on-device-vs-cloud) discussed in our edge computing guide.
Raspberry Pi Hardware Choices
For production:
Need help building a production IoT gateway? [Contact Code Caracal](/contact) — we've shipped these systems for clients across 15+ countries.