How to connect a PLC to the cloud over Modbus TCP
To connect a PLC to the cloud, put a small gateway on the same local network as the PLC. The gateway acts as a Modbus TCP client that polls the PLC on port 502, and it forwards what it reads to your backend over HTTPS. The PLC never talks to the internet and the cloud never talks Modbus. The gateway translates between the two and buffers every event on local disk, so a dropped connection or a power cut loses nothing. A Raspberry Pi running a single Go binary is enough hardware for the job.
This is the design I built for OmniVerify, an industrial dispense-control platform. The cloud backend authorises each dispense and a PLC runs the pump.
The architecture: PLC, gateway, backend
- The PLC runs the physical process: pump relay, flow meter, siren. It makes every real-time decision and acts as the Modbus TCP server.
- The gateway is a Modbus TCP client towards the PLC and an HTTPS client towards the cloud. It polls status, writes instructions, and queues events.
- The backend is the source of truth for transactions and decides whether a dispense is allowed.
The Modbus TCP basics you need
The Modbus.org TCP/IP implementation guide reserves TCP port 502 for Modbus. The PLC is the server (older documentation says slave). It exposes coils, which are single bits, and holding registers, which are 16-bit words. There is no push, so the gateway learns everything by polling. It needs five function codes from the Modbus application protocol specification.
| Code | Function | What the gateway uses it for |
|---|---|---|
| 01 | Read Coils | The status bank: session active, each outcome, alarms, all in one request |
| 03 | Read Holding Registers | Litres, counters, timestamps and PLC settings |
| 05 | Write Single Coil | Start a session, reset an alarm, request a clock sync |
| 06 | Write Single Register | The authorised setpoint and the manual-override password |
| 16 | Write Multiple Registers | Values that span two registers, such as a 32-bit session ID, and the clock |
Word order is not in the spec
The specification defines registers as 16 bits. A float32 litre value or a 32-bit counter therefore spans two registers, and the order of those two words is whatever the PLC programmer chose. Ours is low word first and checked against the HMI display. I pinned it with a test that decodes a snapshot captured from the live PLC, because a flipped word order produces a plausible wrong number, not an error.
An exception response is not a dropped connection
A Modbus exception means the socket is healthy and the PLC rejected the request. Only a transport failure should trigger a reconnect, with backoff from 500 ms up to 30 seconds. Some PLCs refuse new connections after a handful of reconnects, so a client that re-dials on every error can lock itself out.
Polling: read the whole coil bank at once
The gateway polls once a second and reads every status coil it needs in a single Read Coils request over one contiguous range. Reading coils one at a time tears across PLC scan cycles: you can see the finished bit from one scan and the result bit from the next. A session end is the falling edge of the session-active coil. Fetching new instructions from the backend runs on its own slower loop, every three seconds: a one-second fetch is 86,400 requests a day, mostly for empty responses.
Measured live calls averaged about a millisecond, so a 500 ms per-call deadline leaves wide headroom without letting a hung read blind the gateway for long.
Writing to the PLC: the setpoint handshake
A dispense cannot be undone, so the write path is ordered to rule out a double dispense.
- Fetch the pending instruction from the backend and validate it. The setpoint must be a whole number of litres, because the register holding it is an integer.
- Refuse if the PLC already reports an active session.
- Write the setpoint, the manual-override password and the session ID.
- Record the instruction as applied in the local database before touching the start coil. Otherwise a power cut just after the pulse lets the next boot apply it again.
- Raise the start coil and hold it high until the PLC reports the session active, then drop it. A fixed-length pulse is not guaranteed to line up with the PLC's scan cycle.
- Acknowledge the instruction to the backend.
Read the PLC's verdict, do not recompute it
The PLC knows whether a dispense completed, stopped early, or never flowed, and it raises an alarm when flow happens without an authorised instruction. It publishes each outcome on its own coil. The gateway takes the outcome from those coils and does not reproduce the PLC's decision logic. Two copies of that logic will eventually disagree.
Timing matters here. The PLC decides whether unauthorised flow was high or low only after a configurable timer window. The coil that resets the alarm also clears that verdict. Resetting early erased the verdict before the PLC wrote it, so the gateway holds the alarm record across poll ticks until the window has elapsed, then records the verdict, then resets.
Offline first: a SQLite WAL queue and idempotent sync
- Every event is written to SQLite with
journal_mode=WALandsynchronous=FULLbefore any upload. FULL, not NORMAL: a recorded dispense has to survive a power cut. - A separate loop drains the queue to the backend. Only HTTP 400 and 422 count as permanent failures. A 429 pauses the drain for the Retry-After period instead of hammering the server.
- Undelivered events retry for seven days and are then dead-lettered. Dead-lettered rows are never deleted, because they may be the only record of a dispense.
- Each event gets an idempotency key when it is queued, and the backend indexes it uniquely per device. A retry after a lost response cannot create a duplicate.
- Gateway state is written only when it changes. Writing it every tick would have pushed the SD card from about 12 MiB a day to about 338 MiB.
Keep the PLC clock honest
PLC timestamps end up in an audit trail. A Raspberry Pi has no real-time clock, so after a cold boot its time can be wrong by decades until NTP syncs. The gateway pushes the time to the PLC once an hour, only after the Pi's clock is confirmed synchronised and never while a transaction is open. It writes the time registers first, then sets a request coil. The PLC applies the time and clears the coil itself.
Running a Raspberry Pi Modbus gateway unattended
- One static binary. The gateway is Go, built with
CGO_ENABLED=0and a pure-Go SQLite driver, so it cross-compiles for arm64 with no runtime on the Pi. That is about 7 MB, against roughly 150 MB for the Node.js version I tried first. - systemd supervision.
Type=notify,Restart=alwaysandWatchdogSec=30. The process pings the watchdog only while its loops are making progress, so a hung loop gets restarted. - Sandboxing has side effects.
ProtectClock=truekills the process for merely reading the clock state throughadjtimex. The first deploy crash-looped on it; the NTP check now reads systemd-timesyncd's own sync marker file instead. - Power. The SD card risk is corruption on power loss, not wear. The right mitigation is an industrial card with power-loss protection plus a UPS hat that triggers a clean shutdown, which the gateway handles as an ordinary SIGTERM.
Where the cloud side fits
Once events arrive over HTTPS, the backend is ordinary server work. For that half, see designing an event-driven serverless platform on AWS. More of my projects are on the homepage.
I take on fractional CTO and senior architecture contracts through Apex Technology, working remotely. Email info@zayinkrige.com or read the full CV.