A lightweight Modbus TCP proxy with in-memory caching. Designed to reduce load on Modbus devices by serving cached responses to multiple clients.
- Caching: In-memory cache with configurable TTL
- Request coalescing: Identical concurrent requests share a single upstream fetch
- Read-only mode: Optionally block or ignore write requests
- Auto-reconnect: Automatic upstream reconnection on failure
- Stale data fallback: Optionally serve stale cache on upstream errors
- Request diagnostics: Structured lifecycle timing, retry, exception, and health state
- Graceful shutdown: Complete in-flight requests before terminating
- Minimal footprint: ~6MB Docker image (scratch base)
docker run --rm \
-e MODBUS_UPSTREAM=192.168.1.100:502 \
-p 5502:5502 \
ghcr.io/tma/mbproxyAll configuration is via environment variables:
| Variable | Description | Default |
|---|---|---|
MODBUS_LISTEN |
TCP address to listen on | :5502 |
MODBUS_UPSTREAM |
Upstream Modbus device address | (required) |
MODBUS_SLAVE_ID |
Default slave ID | 1 |
MODBUS_CACHE_TTL |
Cache time-to-live | 10s |
MODBUS_CACHE_SERVE_STALE |
Serve stale data on upstream error | false |
MODBUS_READONLY |
Read-only mode: false, true, deny |
true |
MODBUS_ATTEMPT_TIMEOUT |
Per-attempt upstream socket timeout | 10s |
MODBUS_TIMEOUT |
Deprecated alias for MODBUS_ATTEMPT_TIMEOUT |
unset |
MODBUS_REQUEST_TIMEOUT |
Total request budget, including coalescing, queueing, reconnect, retry, and pacing | 30s |
MODBUS_REQUEST_DELAY |
Minimum interval between successful upstream requests | 0 (disabled) |
MODBUS_CONNECT_DELAY |
Silent period after connecting to upstream | 0 (disabled) |
MODBUS_SHUTDOWN_TIMEOUT |
Graceful shutdown timeout | 30s |
LOG_LEVEL |
Log level: INFO, DEBUG |
INFO |
The end-to-end request budget always caps each individual attempt. A full read
retry budget needs room for two attempt timeouts, two connect delays, request
pacing, and any dial time. Pacing is a context-aware pre-wire wait charged to
the next request's budget; it never delays an already received response.
Existing configurations may keep using MODBUS_TIMEOUT during migration.
When both attempt timeout variables are set, their parsed durations must match
or startup fails. Neither setting changes MODBUS_REQUEST_TIMEOUT.
Downstream exceptions preserve genuine upstream Modbus exception responses with
nonzero exception codes. Upstream transport, framing or malformed exception
failures and total request deadlines map to gateway target failed to respond
(0x0B). Local internal failures map to server failure (0x04), while local
validation uses the standard validation exception codes.
/mbproxy -health performs an internal upstream connectivity check and does not open a separate local TCP health port.
Upstream lifecycle logs identify requests with slave_id, func, addr,
qty, and write; write payloads are never logged. Timing fields separate
queue_duration, attempt_duration, reconnect_duration, and
total_duration. Retry and failure records also include attempt, attempts,
error_kind, will_retry, and the preserved exception_code or mapped
downstream_exception when applicable.
The first retryable read transport failure is logged at DEBUG with
will_retry=true; a recovered second attempt is logged at DEBUG with
attempts=2. Final upstream failures and genuine upstream Modbus exceptions
are WARN records. Request cancellation is DEBUG. Requests already canceled or
expired do not select stale fallback, and the fallback record does not claim
downstream delivery. Coalesced followers report coalesced=true,
coalesced_wait_duration, and zero attempts so they are not mistaken for
independent upstream traffic. A follower that outlives a canceled leader and
performs the replacement fetch instead reports coalesced_waited=true with
coalesced=false, preserving both its wait and its real upstream attempt.
The health response includes cumulative total_retries and
recovered_retries, consecutive first-attempt and final-failure counts, the
last first-attempt success, the last successful request, and degradation flags.
A recovered retry immediately sets degraded=true but remains HTTP 200 with
status ok. If no first-attempt success clears that state for one minute,
status becomes degraded while remaining HTTP 200. A final upstream failure
still returns HTTP 503 unhealthy. Success timestamps are null until the
corresponding success has occurred.
false: Full read/write passthrough to upstream devicetrue: Silently ignore write requests, return success responsedeny: Reject write requests with Modbus illegal function exception
services:
mbproxy:
image: ghcr.io/tma/mbproxy
ports:
- "5502:5502"
environment:
MODBUS_UPSTREAM: "192.168.1.100:502"
restart: unless-stoppedservices:
mbproxy:
image: ghcr.io/tma/mbproxy
ports:
- "5502:5502"
environment:
MODBUS_LISTEN: ":5502"
MODBUS_UPSTREAM: "192.168.1.100:502"
MODBUS_SLAVE_ID: "1"
MODBUS_CACHE_TTL: "10s"
MODBUS_CACHE_SERVE_STALE: "false"
MODBUS_READONLY: "true"
MODBUS_ATTEMPT_TIMEOUT: "10s"
MODBUS_REQUEST_TIMEOUT: "30s"
MODBUS_REQUEST_DELAY: "0"
MODBUS_CONNECT_DELAY: "0"
MODBUS_SHUTDOWN_TIMEOUT: "30s"
LOG_LEVEL: "INFO"
restart: unless-stoppedservices:
inverter-proxy:
image: ghcr.io/tma/mbproxy
ports:
- "5502:5502"
environment:
MODBUS_UPSTREAM: "192.168.1.100:502"
MODBUS_CACHE_TTL: "10s"
meter-proxy:
image: ghcr.io/tma/mbproxy
ports:
- "5503:5502"
environment:
MODBUS_UPSTREAM: "192.168.1.101:502"
MODBUS_CACHE_TTL: "2s"# Build Docker image
docker build -t mbproxy .
# Run tests
docker build --target test .
# Or run tests directly
docker run --rm -v $(pwd):/app -w /app golang:1.24 go test ./...| Code | Function |
|---|---|
| 0x01 | Read Coils |
| 0x02 | Read Discrete Inputs |
| 0x03 | Read Holding Registers |
| 0x04 | Read Input Registers |
| 0x05 | Write Single Coil |
| 0x06 | Write Single Register |
| 0x0F | Write Multiple Coils |
| 0x10 | Write Multiple Registers |
- Key format: values are cached per register/coil as
{slave_id}:{function_code}:{address} - Read requests: Served from cache only if every register/coil in the requested range is present and not expired
- Cache misses: If any value in the requested range is missing or expired, the full range is fetched from upstream and decomposed into per-register/coil cache entries
- Write requests: Before an allowed write is forwarded, its generation is incremented and the written range is invalidated. The generation is incremented and the range invalidated again after every outcome, so neither older reads nor reads that execute in the write scheduling window can leave pre-write values cached.
- Request coalescing: Multiple identical range requests in the same write generation share a single upstream fetch using
{write_generation}:{slave_id}:{function_code}:{start_address}:{quantity}as the coalescing key - Stale fallback: If enabled, expired entries are retained and can be served when upstream transport requests fail. Upstream Modbus exceptions are never replaced with stale data.
MIT