Back to Blog
IoT Engineering

Building a Production IoT Gateway with Raspberry Pi and Node.js

A Raspberry Pi sitting between your sensors and the cloud isn't just a bridge — it's a critical reliability layer that buffers data during outages, translates protocols, and reduces cloud egress costs. Here's how to build one that survives production.

February 19, 2024
12 min read
Raspberry PiIoT GatewayNode.jsMQTT

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:

  • 1. Write-ahead logging (WAL): db.pragma('journal_mode = WAL') — concurrent reads don't block writes
  • 2. Synchronous mode: db.pragma('synchronous = NORMAL') — safe against OS crashes, fast enough for sensor rates
  • 3. Max buffer size: cap at 50,000 rows (~72 hours at 10 readings/minute) to prevent disk exhaustion
  • 4. FIFO draining: always forward oldest records first to maintain time-series order in the cloud
  • For 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:

  • Pi 4 (4GB): enough RAM and CPU for simultaneous BLE scanning, Zigbee coordinator, and MQTT forwarding for up to 50 sensors
  • Industrial SD cards (SanDisk Max Endurance, Kingston Endurance): standard SD cards wear out within months under database write loads
  • UPS HAT (Witty Pi, PiJuice): prevents file system corruption during power loss
  • Aluminum enclosure with DIN rail mount: for industrial/panel installations
  • Need help building a production IoT gateway? [Contact Code Caracal](/contact) — we've shipped these systems for clients across 15+ countries.

    ⚡
    Written by CodeCaracal Engineering

    We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

    Let's Build Together

    Got an IoT challenge?
    We've shipped it.

    Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

    No upfront commitment99.9% uptime SLANDA on requestFixed-price options