DEV Community

Cover image for ThingsBoard CE doesn't speak LoRaWAN — here's a Spring Boot bridge that fixes it
David Gerber
David Gerber

Posted on

ThingsBoard CE doesn't speak LoRaWAN — here's a Spring Boot bridge that fixes it

One service that unifies Swisscom LPN **and* The Things Network webhooks into a single ThingsBoard CE backend — without paying for the Professional Edition.*

ThingsBoard is one of the most popular open-source IoT platforms out there — 18k+ GitHub stars, used in production by teams from smart-building integrators to industrial automation shops, and shipped with a genuinely generous Community Edition (CE) that you can self-host for free. Device management, rule chains, dashboards, alarms — it's all in CE.

But if you've tried to wire LoRaWAN devices into ThingsBoard CE specifically, you've probably hit the same wall I did: ThingsBoard's LoRaWAN integration ships with the Professional Edition only. CE users get MQTT, HTTP, and CoAP transport adapters — but no first-class adapter for Swisscom LPN, The Things Stack, or any other LoRaWAN network server.

What made my situation slightly worse: I had devices on two different LoRaWAN networks.

  • Some sensors on Swisscom LPN — Switzerland's nationwide carrier-grade LoRaWAN network, running on Actility's ThingPark platform.
  • Others on The Things Network v3 — the global, community-operated LoRaWAN backbone, used by hundreds of thousands of devices worldwide.

Same physical radio standard. Same destination dashboard. Two completely different webhook contracts — and ThingsBoard CE can't ingest either of them natively.

So I built an open-source Spring Boot service that sits between both LoRaWAN networks and ThingsBoard CE. One service, two networks, one dashboard.

Repo: https://github.com/geda/lorawan-tb-gateway

What it does

Each LoRaWAN network gets its own HTTP endpoint on the gateway:

  • POST /api/v1/swisscom/uplink — Swisscom LPN's DevEUI_uplink envelope (Actility shape).
  • POST /api/v1/ttn/uplink — The Things Stack v3 webhook payload.

When an uplink arrives, the gateway validates the payload, normalizes it into a network-agnostic internal model, makes sure the corresponding device exists in ThingsBoard (creating it if not), and pushes the telemetry to ThingsBoard's device API. The result is a single ThingsBoard tenant where devices from both LoRaWAN networks coexist — same dashboards, same rule chains, same alarm thresholds, same user permissions. A Swisscom-side sensor and a TTN-side sensor are just two devices in the same tenant; they only differ by the source field in their telemetry stream.

What you actually get out of the box

Beyond just "forwarding webhooks", a few things make this nicer than rolling your own:

Auto-provisioning. The first time a deveui you've never seen before sends an uplink, the gateway creates a matching device in ThingsBoard (using the deveui as the device name and a configurable device type), fetches its access token, caches it, and forwards the telemetry. Add a new LoRaWAN sensor in the field, plug it in — it shows up in your ThingsBoard dashboard a few seconds later. No portal clicks, no token copy-paste.

TTN payload decoding propagated to ThingsBoard. If you've configured a TTN payload formatter on your device (the JavaScript decoder in the TTN console), the resulting decoded_payload object gets flattened into the ThingsBoard telemetry envelope. A device decoding to {"battery": 3.27, "distance": 548, "leak": 1} lands in ThingsBoard as three chartable telemetry keys with the right numeric types — no rule-chain massaging needed. You can drag them straight into a widget.

A unified payload shape across both networks. Swisscom sends payloads as hex strings; TTN sends them as base64. The gateway lower-cases hex, decodes base64 to hex, and presents both as payloadHex in ThingsBoard. If you're building dashboards that should work for "all my devices" regardless of which LoRaWAN provider they're on, this matters.

Optional ThingsBoard backend. When the THINGSBOARD_URL environment variable is unset, the gateway still runs and accepts uplinks — it just logs them instead of forwarding. Useful for local development, integration testing, or sanity-checking your network server's webhook configuration before the dashboard side is ready.

Per-network webhook authentication. Each endpoint can require a shared-secret header (X-Gateway-Token) configured at the LoRaWAN provider side. Constant-time comparison, fail-fast startup mode (refuses to boot if a token is missing in prod), oversize-request rejection — the gateway is built to live on the public internet.

Small footprint. The container uses around 80 MB of RAM at idle and runs on ARM (Raspberry Pi 4 is fine). No external dependencies beyond ThingsBoard itself — no Redis, no Kafka, no separate Postgres.

Running the full stack on your laptop

This is where it gets fun. The repo ships a deploy/compose.yml that gives you the entire pipeline — ThingsBoard CE, the gateway, and (optionally) a Cloudflare Tunnel for public ingress — in a single docker compose up.

The services

The compose file defines three services on a shared internal Docker network (tb-network):

Service Image What it does
thingsboard thingsboard/tb-postgres:latest Full ThingsBoard CE with embedded PostgreSQL. Exposes MQTT (1883), HTTP transport (7070), and CoAP/LWM2M (5683-5688 UDP) on the host. The HTTP UI port (9090 inside the container) is reached via the gateway/tunnel and not published on the host by default.
gateway geda73/lorawan-tb-gateway:latest This project. Talks to ThingsBoard internally at http://thingsboard:9090. No host port exposed by default (uncomment a line if you want to curl it directly).
cloudflared cloudflare/cloudflared:latest Optional. Tunnels public webhook traffic from your Cloudflare-managed domain to the gateway, with TLS terminated at Cloudflare's edge. Skip this if you're just running locally.

The ThingsBoard data lives in two named volumes (tb-data, tb-logs) so it survives docker compose down.

Quickstart

The repo's deploy/ directory has both compose.yml and a .env.example. Clone, copy, fill in:

git clone https://github.com/geda/lorawan-tb-gateway.git
cd lorawan-tb-gateway/deploy
cp .env.example .env
chmod 600 .env

# Edit .env — at minimum:
# TB_USERNAME=tenant@thingsboard.org
# TB_PASSWORD=<your choice>
# GATEWAY_SECURITY_SWISSCOM_TOKEN=$(openssl rand -hex 32)
# GATEWAY_SECURITY_TTN_TOKEN=$(openssl rand -hex 32)
# GATEWAY_SECURITY_REQUIRE_TOKEN=true

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

The first boot takes a minute or two — ThingsBoard initializes its database, the gateway waits for ThingsBoard to come up, and then both settle. You can watch progress with:

docker compose logs -f
# or just the gateway:
docker compose logs -f gateway
Enter fullscreen mode Exit fullscreen mode

Logging in for the first time

If you opted out of the Cloudflare tunnel (or you want to access ThingsBoard locally during setup), uncomment the port mapping in the thingsboard service to expose 9090:9090, then open http://localhost:9090.

The tb-postgres image ships with three pre-created accounts:

Role Email Default password
Sysadmin sysadmin@thingsboard.org sysadmin
Tenant admin tenant@thingsboard.org tenant
Customer user customer@thingsboard.org customer

Change all three before exposing anything to the internet. The .env.example comments call this out — TB_PASSWORD in the env file is what the gateway will use to log in as the tenant admin; you also need to update the password from ThingsBoard's UI to match (or rotate it once and update both).

Sending fake uplinks (no real LoRaWAN hardware required)

The repo ships two PowerShell scripts in scripts/ that post real-shape payloads to a running gateway. Handy for verifying the wiring before you start moving real devices over:

# Swisscom LPN shape (DevEUI_uplink envelope, FPort/FCntUp as quoted strings)
./scripts/send-swisscom-uplink.ps1 -BaseUrl http://localhost:8080 `
    -DeviceEui 70B3D57ED0001234 -PayloadHex AABBCC

# TTN v3 shape (end_device_ids + base64 frm_payload + optional decoded_payload)
./scripts/send-ttn-uplink.ps1 -BaseUrl http://localhost:8080 `
    -DeviceEui 70B3D58FF00000BB
Enter fullscreen mode Exit fullscreen mode

Each script takes a -Token parameter that defaults to the matching GATEWAY_SECURITY_*_TOKEN environment variable — set it once in your shell, run the scripts bare. The first successful uplink creates the device in ThingsBoard; subsequent uplinks add telemetry rows to it. Watch it land in Devices → 70B3D57ED0001234 → Latest Telemetry in the ThingsBoard UI.

Wiring real LoRaWAN webhooks

Once the local stack is happy, point your real network servers at the gateway:

  • Swisscom LPN portal → your Application Server connection → set the destination URL to https://<your-host>/api/v1/swisscom/uplink, add a custom header X-Gateway-Token: <value of GATEWAY_SECURITY_SWISSCOM_TOKEN>, save.
  • TTN console → Application → Integrations → Webhooks → Add webhook → URL https://<your-host>/api/v1/ttn/uplink, add a header X-Gateway-Token: <value of GATEWAY_SECURITY_TTN_TOKEN>, enable "uplink message", save.

If you're using the Cloudflare Tunnel from the compose stack, your <your-host> is whatever hostname you configured in Cloudflare Zero Trust. If you're running on a public VM, that's your domain name (terminate TLS in front — Caddy / nginx / Cloudflare Access all work). Don't expose port 8080 directly to the internet — the service is designed to live behind a TLS terminator.

Tearing it down

docker compose down              # stop containers, keep data
docker compose down -v           # nuke everything including the TB database
Enter fullscreen mode Exit fullscreen mode

The TB database is in a named volume, so docker compose down alone leaves your devices, dashboards, and rule chains intact for the next boot.

A few design choices worth knowing

For readers who'll dig into the code, three decisions that aren't obvious from the README:

  • No ThingsBoard REST SDK. The official org.thingsboard:rest-client is broken under Spring Boot 4 (Jackson 3 vs Jackson 2 incompatibility in its login flow). The gateway calls TB's REST API directly via Spring's modern RestClient — about 200 lines, all the response shapes modeled as Java records. Shorter than the SDK once you only use what you need.
  • JWT caching with retry-on-401, not refresh-token plumbing. TB issues short-lived JWTs. Instead of tracking expiry and refreshing proactively, the gateway caches the JWT, and on a 401 it invalidates and re-logs in once. If that 401s too, propagate — credentials are wrong, not just expired.
  • The ThingsBoard adapter is optional via @ConditionalOnProperty. When THINGSBOARD_URL is unset, the adapter beans aren't created, the forwarder receives empty Optional<>s, and uplinks are logged instead of forwarded. The HTTP endpoints still respond 202 Accepted. This is what makes mvn spring-boot:run on a developer laptop Just Work without a local ThingsBoard.

The full code is on GitHub — these three are the ones that took the longest to get right.

Security: it's a webhook receiver, treat it like one

Both network servers post over the public internet. The gateway ships three layers of defense, all configurable via env vars:

  • Per-network shared secret. Each endpoint can require X-Gateway-Token: <value>, constant-time compared. Configure the matching secret as a custom header on the Swisscom/TTN webhook integration.
  • Fail-fast startup. Set GATEWAY_SECURITY_REQUIRE_TOKEN=true and the service refuses to start unless both tokens are configured. Recommended for production — it prevents the "I forgot the env var" fail-open.
  • Body-size cap. GATEWAY_SECURITY_MAX_BODY_BYTES (default 32 KB) rejects oversize requests with 413 Content Too Large before Jackson reads the body. Real LoRaWAN uplinks are well under 4 KB; the cap kills the easy DoS vector.

The shared-secret model has a known limitation: it's replayable if leaked. HMAC verification (binding the secret to the request body) is on the roadmap for Swisscom — Actility supports it natively. TTN doesn't sign webhook bodies, so the right answer there is Cloudflare Access (or any zero-trust front-door) in front, which is what my production setup uses anyway and what the bundled compose.yml is wired for.

Constraints worth knowing

To be upfront about what this is not:

  • Single-tenant. One ThingsBoard tenant per gateway instance. For SaaS-style hosting you'd want per-token tenant binding — happy to take a PR.
  • Uplink-only. Telemetry flows up; there's no downlink endpoint yet, so actuator devices need a separate path.
  • No queue. Failures during the TB call surface as a failed POST back to your network server. Both Swisscom and TTN retry, so for short outages it's fine. For longer ones, you'd want a durable buffer in front.

If any of those is a hard requirement for you, the repo's a starting point, not a finished product. The architecture is deliberately set up so adding a third LoRaWAN network is a new DTO, a new mapping method, and a new controller — everything downstream of the common internal UplinkMessage stays untouched.


Repo: https://github.com/geda/lorawan-tb-gateway
Docker image: geda73/lorawan-tb-gateway:latest
License: MIT

If you're running ThingsBoard CE and have LoRaWAN devices on Swisscom LPN, The Things Network, or both — this should slot right in. If you're on a third LoRaWAN provider (Helium, ChirpStack, Loriot, Senet…) and want to add support, the extension pattern is documented in the README and the codebase is small enough to fork in an afternoon.

A star on the repo helps other ThingsBoard CE users find it. Bug reports, "it worked with my LoRaWAN provider" feedback, and pull requests for additional network integrations are all welcome.

Top comments (0)