OpenPLC Runtime v4 vs v3: What Changed and How to Migrate (2026)
Last Updated: September 23, 2026
If you still run an OpenPLC v3 box on a Raspberry Pi or an industrial PC, you are now running unmaintained software. The v3 repository carries an end-of-life banner pointing to its successor, and GitHub shows it as archived and read-only since April 4, 2026. The comparison of OpenPLC Runtime v4 vs v3 is therefore not a feature beauty contest. It is a forced migration, and the new runtime differs in almost every layer. The browser UI is gone, the compiler moved, protocols became plugins, and a REST API with JWT authentication replaced the old login page. The v4.2 line, released on September 2, 2026 and patched to v4.2.3 on September 18, finally adds the one thing many v3 users were waiting for: retained variables. This guide explains what changed, why it changed, what breaks, and how to move a working v3 controller to v4 without surprising the machine it drives.
What this covers: the v4 dual-process architecture against the v3 monolith, the new deployment API, a protocol-by-protocol plugin map, what v4.2 added, a decision tree for when to migrate, a step-by-step migration runbook, and the gotchas that bite in production.
Context and Background
OpenPLC began as an academic project and grew into the most widely used open-source soft PLC. Version 3 packaged everything into one installable bundle. You got a Flask web interface on port 8080 for uploading programs, the MatIEC compiler that translated IEC 61131-3 Structured Text into C on the device, a runtime core, and built-in protocol servers for Modbus TCP, DNP3 and EtherNet/IP that you switched on from the web UI. The installer took a platform argument (win, linux, docker, rpi or custom), and an optional ./install.sh linux ethercat build added EtherCAT. We covered that design in detail in our OpenPLC v3 architecture and Modbus TCP explainer, which is now the legacy baseline for everything below.
That design was simple, and it had predictable weaknesses. The web UI and the runtime shared a trust boundary, so anyone who reached port 8080 with a valid login (often left at its defaults) could upload and start code. Every protocol server was compiled into the runtime, so adding a protocol meant forking the codebase. And compilation on the target meant a full toolchain on every controller.
Autonomy Logic, the company now stewarding OpenPLC, rebuilt the runtime as a separate project, Autonomy-Logic/openplc-runtime. The release history shows how fast it moved:
- v4.0.0 shipped on December 31, 2025, followed by nine v4.0.x patch releases through March 5, 2026.
- v4.1.0 went through four release candidates between April 3 and May 22, 2026. Stable v4.1.x releases followed from June 5 to v4.1.10 on August 11.
- v4.2.0 and v4.2.1 both shipped on September 2, 2026. v4.2.2 followed on September 4 and v4.2.3 on September 18.
Meanwhile the OpenPLC v3 repository states that the project has reached end of life and has been replaced by Runtime v4. No more fixes are coming, security fixes included. For a controller that touches physical equipment, that is the decisive fact. The move from v3 to v4 is also a change of model. A self-contained appliance became a headless service driven by a desktop IDE or a cloud console. That puts OpenPLC in the same conversation as the commercial runtimes in our CODESYS vs TwinCAT soft PLC comparison, which have always separated the engineering tool from the runtime.
OpenPLC Runtime v4 vs v3: The Architecture That Changed
Direct answer: OpenPLC Runtime v4 is a headless controller with no browser interface. A Python/Flask REST API on HTTPS port 8443 with JWT authentication accepts programs from OpenPLC Editor v4. A separate C/C++ core runs scan cycles under SCHED_FIFO real-time priority. Programs load as shared libraries, and protocols run as Python or native plugins.

Figure 1: The v3 monolith against the v4 split into an API process, a real-time core and pluggable protocol drivers.
The top chain is v3. A browser talks to the Flask web UI on port 8080, the device compiles ST to C with MatIEC, and the runtime core hosts the Modbus, DNP3 and EtherNet/IP servers directly. The bottom chain is v4. The Editor talks to an authenticated REST API. The API talks to the real-time core over Unix domain sockets. The core loads the user program as a .so file and delegates every fieldbus and network protocol to a plugin driver that hosts Python and native plugins.
The runtime is headless, and the Editor is the only front end
The v4 README is blunt about this. Do not point a browser at https://localhost:8443, because unlike the v3 runtime there is no web interface there. You configure the runtime’s IP address and credentials in OpenPLC Editor v4, the desktop application, or you manage it through the Autonomy Edge cloud console. The API is described as internal: it exists for the Editor, though integrators can script it.
This is the change that trips up v3 users on day one. Your muscle memory of “open the PLC’s web page, upload the .st file, press Start” has no v4 equivalent. The practical consequence is that every engineer who touches the controller needs the Editor installed. Program source lives with the Editor project, not on the controller, at least until v4.2’s Retrieve Project feature, covered below.
The benefit is a much smaller attack surface on the controller. v3 served an HTML application with session cookies. v4 serves JSON endpoints behind TLS, with bearer tokens. Everything except first-user creation, login and a user-info query requires Authorization: Bearer <token>. Passwords are hashed with PBKDF2-SHA256 at 600,000 iterations with a salt and a pepper, according to the README’s security section. The runtime generates a self-signed TLS certificate on first run, which the Editor accepts automatically.
Two processes, joined by Unix sockets
v4 splits the controller into two processes with different jobs and different timing needs. docs/ARCHITECTURE.md describes them this way:
- REST API server (Python/Flask). It handles HTTPS on port 8443, the Socket.IO debug interface, compilation orchestration, user authentication and supervision of the runtime process.
- PLC runtime core (C/C++). The
build/plc_mainexecutable runs the scan cycle, manages I/O through plugins, serves debug requests, runs a watchdog and tracks lifecycle state.
They talk over two Unix domain sockets. /run/runtime/plc_runtime.socket carries text commands (start, stop, status) with synchronous replies. /run/runtime/log_runtime.socket streams logs from the core to the API. The core’s lifecycle states are EMPTY (no program), INIT, RUNNING, STOPPED and ERROR.
The design point is isolation of timing. The scan thread runs under SCHED_FIFO, the Linux real-time scheduling class. A runnable FIFO thread preempts every normal-priority thread, including the Python interpreter serving HTTP requests. Each cycle does four things: read inputs through the plugin drivers, execute the compiled program, write outputs, then sleep until the next period with clock_nanosleep(). The core tracks minimum, maximum and average scan time, cycle time, latency and overruns, and a stats thread logs them every five seconds.
A watchdog thread watches an atomic heartbeat that the scan loop updates every cycle. If the heartbeat does not move for two seconds while the PLC is RUNNING, the watchdog terminates the process instead of letting a hung controller sit with frozen outputs. Design for it explicitly. A v4 program with an infinite loop gets killed, and you need to decide what your outputs do when the process dies. That is a hardware and wiring question, not a software one.
Running under SCHED_FIFO needs root or the CAP_SYS_NICE capability. That is why the Docker instructions add --cap-add=SYS_NICE and --cap-add=SYS_RESOURCE. The README’s basic Docker example omits these flags, so do not rely on a missing capability failing loudly. Without it the scan loop cannot be expected to get real-time priority, and on a busy host you then get the jitter the architecture was built to avoid. The project publishes no official jitter or cycle-time figures comparing v4 with v3, and we do not quote any here. Measure on your own hardware with the core’s own statistics.
Programs compile to shared libraries and hot-swap
In v3 the device ran MatIEC to turn your ST into C, then compiled the C into the runtime. In v4 the Editor does the IEC-to-C front end and uploads generated sources. The runtime only has to build and link them. Per docs/COMPILATION_FLOW.md, the upload ZIP contains Config0.c, Res0.c, debug.c, glueVars.c, c_blocks_code.cpp, LOCATED_VARIABLES.h and a lib/ directory of IEC type headers. The runtime compiles them with -O3 -fPIC into build/new_libplc.so, then renames the file to build/libplc_<unix-timestamp>.so.
Loading is a dynamic-library swap. The core stops the PLC and calls dlclose() on the old library. It then calls dlopen() on the new one, resolves the init and run entry points with dlsym(), initialises and starts. What happens when compilation fails is where the docs disagree. COMPILATION_FLOW.md says the old program “remains loaded (if any)”, which would be the right failure mode for a controller. The newer RETRIEVE_PROJECT.md states that “a failed build leaves the device with no program at all”. Until you have tested it on your tag, assume the worse case: a bad upload can leave the controller empty and stopped, so never upload untested logic to a running machine.
A caveat on the toolchain. The v4 documentation is mid-transition. COMPILATION_FLOW.md still describes the Editor’s local pipeline as JSON to XML to ST to C, built with scripts/compile.sh and gcc. The README and the architecture doc refer to a STruC++ pipeline that generates C++, and the architecture doc names a strucpp_get_config() entry point. The fairest summary is that v4 is moving to a STruC++ C++ pipeline. The v4.1.0-rc.4 release note adds a practical consequence: the Editor reads the runtime version from a new /api/version endpoint and blocks STruC++ uploads to runtimes older than 4.1.0. Match your Editor and runtime versions. Treat an Editor upgrade as a runtime upgrade trigger.
Protocols moved out of the core into plugins
v3 compiled its protocol servers into the runtime. v4 keeps the core protocol-agnostic and loads plugins listed in a plugins.conf file. Each line has six comma-separated fields: name,path,enabled,type,config_path,venv_path. Type 0 is a Python plugin and type 1 is a native C/C++ shared library loaded through dlopen/dlsym. Python plugins can each have their own virtual environment, so an OPC UA plugin with pinned dependencies cannot break a Modbus plugin with different ones.
Every plugin follows the same lifecycle: init(args), start_loop(), stop_loop() and cleanup(). Native plugins get two extra optional hooks, cycle_start() and cycle_end(). They run inside every scan, before and after the program logic, while the I/O buffer mutex is held. That gives a native fieldbus driver lockstep access to the process image with no extra locking. It also means a slow hook stretches every scan. Python plugins instead run asynchronously in their own threads and reach the image through a SafeBufferAccess wrapper that takes and releases the mutex for each access.
On main as of September 23, 2026, the plugin tree contains:
- Python plugins:
modbus_master,modbus_slaveandopcua(asyncua pinned at 1.1.8), plus examples and asharedhelper package. - Native plugins:
ethercatands7comm, plus examples.
Side-by-side comparison
| Dimension | OpenPLC Runtime v3 | OpenPLC Runtime v4 (4.2.x) |
|---|---|---|
| Maintenance status | End of life; repo archived Apr 4, 2026 | Actively released; v4.2.3 on Sep 18, 2026 |
| Operator interface | Browser web UI, port 8080 | None on device; Editor v4 desktop app or Autonomy Edge |
| API | Web forms and sessions | REST over HTTPS 8443, JWT bearer tokens |
| Process model | Single web app plus runtime | Flask API process plus C/C++ core over Unix sockets |
| Real-time documentation | Not a separately documented layer | SCHED_FIFO scan thread, 2 s watchdog, scan stats |
| Compile location | MatIEC and gcc on device | Editor generates sources; runtime builds a .so |
| Program load | Rebuild runtime | dlopen hot swap; failed-build behaviour documented inconsistently (test it) |
| Modbus TCP | Built-in server | modbus_slave and modbus_master Python plugins |
| DNP3 | Built-in server | Not in plugin tree at time of writing |
| EtherNet/IP | Built-in server | Not in plugin tree at time of writing |
| OPC UA | Not built in | Python plugin (asyncua) |
| S7comm | Not built in | Native plugin |
| EtherCAT | Optional build flag | Native plugin |
| Retained variables | No v4-style retain store | Added in v4.2.0, built-in file store off by default |
| Source on device | Uploaded .st file only, no Editor project |
Full Editor project snapshot (v4.2.0) |
| Install | install.sh [win, linux, docker, rpi, custom] |
Docker image, one-line container installer, or install.sh --native |
| Architectures | Whatever builds from source | Prebuilt amd64, arm64, armv7 |
What v4.2 Added: Retain, Retrieve Project and a Cleaner API
The GitHub release bodies for v4.2.x are empty, so the feature list has to be read from the commits between v4.1.10 and v4.2.0 (28 non-merge commits) and from the docs. Three themes stand out.
Retain variables arrive
IEC 61131-3 defines RETAIN variables: values that survive a power cycle or warm restart, such as batch counters, recipe setpoints and totalisers. Controllers without working retain force engineers into workarounds, like writing values to a file from a function block. v4.2.0 adds retain to the v4 runtime through a series of commits. They cover marshalling of retained values out of the program .so, a plugin-based store, cold reset, and a built-in file store that is off by default.
Two details matter for migration. First, one retain commit is marked breaking (refactor(retain)!). It unifies the retain driver interface so that “the store decides what is stale.” If you wrote a custom retain store against a v4.1 development build, expect to port it. Second, persistent-storage settings now live in the project, not in the webserver configuration. Retain behaviour therefore travels with the program you upload, which is what you want when one Editor project targets several controllers.
The staleness question is the heart of retain design. Say you add a variable to a retained block and upload. The stored image from the old program no longer matches the new layout. Restoring it byte for byte would put values in the wrong variables. v4.2’s answer goes further than a layout check. Per the breaking commit, the runtime hands the store an MD5 identity of the running program. A store that finds values saved by a different program discards them, logs that storage was cleared, and every retained variable starts at its declared initial value. The commit explains why: two programs that happen to share a retained layout must not inherit each other’s state. The consequence is blunt. Uploading a changed program, even one whose retained declarations did not change, can clear retained values. Plan retained-data migrations with the same care as a database schema change.
One more operational detail from the same commits: the built-in file store is disabled by default because it writes to the data partition on a cadence the operator did not choose, which matters for SD-card endurance. Check the write load before enabling it on SD-card-based hardware.
Retrieve Project from PLC
v4.2.0 also adds a project snapshot: “store the source project alongside an uploaded program.” Upload from the Editor and the device can keep a copy of the source project. An administrator can later pull it back through the API. This closes one of the oldest complaints about v3. A v3 controller kept the uploaded .st file in its st_files directory, but that is the Editor’s generated Structured Text, not the editable project with its ladder diagrams, comments and configuration. If the laptop with the project went missing, so did your practical ability to edit the logic.
The project’s own docs/RETRIEVE_PROJECT.md states the security limits plainly. Read them before you turn this on:
- The stored project is a plain ZIP on the filesystem, not encrypted.
- There is no integrity guarantee: no signature and no checksum. You cannot prove the retrieved project matches what is running.
- The project name and timestamp are advertised in the unauthenticated UDP discovery reply. Anyone on the segment can learn what is deployed and when.
- For at-rest protection, the doc recommends full-disk or filesystem encryption.
- An upload that carries no project erases the stored one, so a deploy from a CLI or an older client silently removes the snapshot. The archive is also capped at 100 MB.
In practice, treat Retrieve Project as a convenience backup, not as your system of record. Keep the canonical project in version control, and compare the retrieved ZIP against a hash you recorded at deployment time.

Figure 4: How v4.2 stores the project snapshot and retained values, and what happens on restart and on retrieval.
The sequence shows both features sharing the persistent data directory. On upload, the API stores the project ZIP and the core loads the new library. On start, the core asks the retain store for saved values, and the store either returns them (warm start) or declares them stale (cold reset). While the program runs, retained values are persisted. Separately, an admin can ask the API to return the stored snapshot, which comes back as the same unencrypted ZIP.
API and account clean-up
Two routes were removed in v4.2.0. /password-change was a pre-RBAC route that ignored roles, so any authenticated user could reach it. /project-snapshot/info had no client callers. A fix also rescues a device whose accounts contain no administrator, but only in the clear-cut case. Per RETRIEVE_PROJECT.md, if there is exactly one account and it is not an administrator, it is promoted at startup. With several non-administrator accounts, nothing is promoted. The same commit range opens licence-related debug function codes to any authenticated role and delivers licences for commercial “VPP” plugins over the debug channel. That matters only if you buy plugins from Autonomy Logic.
If you scripted against the v4 API, grep your scripts for those two paths before upgrading. Scripts that change passwords must move to whichever role-aware route docs/API.md documents for your runtime tag.
v4.2.2: the container becomes the default install
v4.2.2 changed no runtime code. Its pull request states that the application is unchanged from v4.2.1. What changed is packaging. The image now declares a Docker HEALTHCHECK on /api/version, deliberately not /api/ping, which requires a JWT and so always returned 401 to a health probe. The release also introduced a bootloader: a small, independently versioned supervisor container on port 8445. It starts the runtime container and lets the Editor change the runtime version without SSH.
The bootloader’s upgrade order is worth copying into your own procedures: pull the new image, stop the old container, start the new one, pass a health gate, and only then remove the old image. A failed pull never stops the running runtime. The PR also records a deliberate choice: there is no automatic rollback. If a version change fails, the device stops and goes into recovery, because “choosing a version has physical consequences.” That is correct for a controller, and it means someone has to be ready to act when an upgrade fails.
Walk-Through: Deploying a Program to v4
Understanding the deployment path helps you debug the Editor when an upload hangs. It also lets you build CI pipelines that deploy without a human clicking Upload.

Figure 2: The OpenPLC Editor v4 upload path, from login to a running program with a live debug session.
Reading the sequence top to bottom:
- Authenticate. The client posts credentials to
/api/loginand gets a JWT. The very first account on a fresh device is created with/api/create-user, which needs no authentication. That is a provisioning risk, covered in the gotchas. - Upload. The client posts the program ZIP as multipart form data to
/api/upload-filewith the bearer token. - Validate. The API checks the upload before extracting it (see the limits below).
- Build. A background thread extracts into
core/generated/, compiles, links and renames the library. The status moves throughUNZIPPING,COMPILINGand thenSUCCESSorFAILED. - Poll. The client polls
/api/compilation-status, which returns the state, the build log lines and the exit code. - Load and run. On success the core swaps libraries. The client calls
/api/start-plc(and/api/stop-plcto halt), and/api/statusreports the state. - Debug. The Editor opens a Socket.IO connection, passing the JWT in the
authpayload, and exchanges hex-encoded debug commands to read and force variables.
An illustrative CI snippet follows. The endpoint paths, the username/password login fields, the access_token response field and the multipart file part match docs/API.md on main at the time of writing, but check the doc at your runtime tag before you rely on them. Note that tokens expire (24 hours by default, per the same doc):
# ILLUSTRATIVE ONLY - verify request/response field names against docs/API.md for your tag
RT=https://plc-line3.example.local:8443
TOKEN=$(curl -sk -X POST "$RT/api/login" \
-H 'Content-Type: application/json' \
-d '{"username":"ci-deployer","password":"'"$PLC_PASS"'"}' | jq -r '.access_token')
curl -sk -X POST "$RT/api/upload-file" \
-H "Authorization: Bearer $TOKEN" -F "file=@build/program.zip"
until curl -sk "$RT/api/compilation-status" -H "Authorization: Bearer $TOKEN" \
| jq -e '.status=="SUCCESS" or .status=="FAILED"' >/dev/null; do sleep 2; done
curl -sk "$RT/api/start-plc" -H "Authorization: Bearer $TOKEN"
Two things about this snippet deserve comment. -k disables certificate verification because the runtime uses a self-signed certificate. In production, pin the certificate’s fingerprint rather than trusting anything. And a CI job that can start a PLC is a safety function. Gate it behind the same change control you use for a manual download to a physical controller.
Upload validation, with numbers
The upload validator is a real security control, and its limits shape how large your program can grow. It performs these checks:
- It requires a valid ZIP format.
- It rejects path traversal:
.., absolute paths and:in entry names. - It caps each file at 10 MB and the whole upload at 50 MB.
- It rejects any entry with a compression ratio above 1000:1, as zip-bomb protection.
- It blocks
.exe,.dll,.sh,.bat,.js,.vbsand.scrfiles. - It strips macOS metadata such as
__MACOSX/and.DS_Store.
A worked example shows where the ratio check can bite. Generated C from a large ladder program is extremely repetitive, so it compresses well. Suppose a 9 MB Res0.c compresses to 12 KB. The ratio is 9,216 KB / 12 KB = 768:1, which passes. If the generator emits 9 MB of near-identical initialisers that compress to 8 KB, the ratio becomes 1,152:1 and the upload is rejected as a suspected zip bomb, even though it is legitimate. It is an edge case, but if a huge generated program fails validation with a ratio error, that is the likely cause. The fix is to restructure the program so the generator emits less duplicated code. Weakening the check is the wrong answer.
Protocols: Mapping v3 Features to v4 Plugins
The protocol question decides most migrations, so it is worth going through one protocol at a time.
Modbus TCP. This is the best-supported path. The modbus_slave plugin maps boolean outputs and inputs to coils and discrete inputs, and integer buffers to holding and input registers. It supports the standard function codes 01, 02, 03, 04, 05, 06, 15 (0x0F) and 16 (0x10) and runs on pymodbus with asyncio. modbus_master covers polling remote slaves, a role v3 handled through its slave-devices page.
Watch two details. The driver documentation’s example configuration listens on port 5020, not 502. Check the port you actually configure, or every HMI will fail to connect after cutover. More importantly, the address map is generated, not fixed. The current Editor documentation describes the Modbus server laying out IEC segments sequentially within each block, starting at address 0. %QW registers come first in the holding-register block, then %MW, then %MD at two registers each, then %ML at four registers each. The segment sizes are configurable, with at most 1,024 registers per segment. If your SCADA tag database was built against v3’s register numbering, do not assume the numbers carry over. Generate the map from the Editor’s Address Mapping Reference and diff it against your HMI tags before cutover.
OPC UA. This is new in v4 and a genuine upgrade. v3 users who needed OPC UA typically ran a separate gateway that polled Modbus and republished the values. The opcua Python plugin, built on asyncua 1.1.8, lets the controller expose an address space directly. It makes OpenPLC a first-class citizen in the architectures we described in our software-defined manufacturing and virtual PLC guide, where OPC UA is the northbound contract.
S7comm. Also new, as a native plugin. Existing Siemens-oriented HMIs and data collectors can talk to OpenPLC over the S7 protocol. According to the Editor’s Modbus addressing page, the S7 server maps all fourteen PLC memory variants directly. That covers the byte, double-word and long segments that Modbus cannot index.
EtherCAT. It moved from a compile-time flag in v3 to a native plugin in v4. Native matters here, because EtherCAT process data exchange belongs inside the scan, which is exactly what cycle_start and cycle_end provide. Release v4.1.3 made plugin loading fail-safe: an enabled plugin that cannot load its symbols (the example given is EtherCAT on Windows without Npcap) now degrades gracefully instead of forcing the runtime into ERROR. That is convenient for a mixed fleet. It also means a missing EtherCAT dependency no longer stops the PLC loudly. Your commissioning checklist must verify that the plugin actually started.
DNP3 and EtherNet/IP. v3 had both as built-in servers, and neither appears in the v4 plugin tree on main at the time of writing. That does not mean they will never exist, since the plugin model makes them straightforward to add. But if your utility SCADA polls OpenPLC over DNP3, or an Allen-Bradley-style HMI talks EtherNet/IP to it, v4 cannot replace v3 today without extra work.
Migration Plan: Moving a v3 Controller to v4
Treat this migration like replacing a controller, not upgrading software. The runtime, the engineering tool, the protocol stack and the network interface all change at once.

Figure 3: A migration decision tree: protocol dependencies decide whether to migrate now, and the target’s container support decides the install path.
The first gate is the protocol inventory. If anything depends on v3’s DNP3 or EtherNet/IP servers, you have three options. Keep that controller on v3, frozen and isolated. Put a protocol gateway in front of a v4 controller that speaks Modbus or OPC UA. Or build the missing plugin yourself. If you need OPC UA, S7comm or EtherCAT, v4 is the only maintained option, so migrate. If the controller speaks only Modbus TCP and local GPIO, v4 is a straightforward target. The second gate is the install method, which depends on whether the target can run a container engine.
Step 1: Inventory the v3 controller
Before touching anything, capture:
- The program source. Export every POU from the original Editor project. If all you have is the device, stop and recover the source first. v3 keeps only the uploaded
.stfile, which is generated Structured Text, not your editable project. - Protocol usage. Which servers are enabled, on which ports, and which clients connect. Check firewall logs or packet captures as well as the v3 settings page.
- The Modbus register map as clients see it. Export the HMI and SCADA tag databases, not just the PLC’s view.
- Slave devices configured in v3, with polling rates and register ranges.
- Scan-time behaviour. Record the cycle time you configured and what the machine actually needs.
- Values that must survive restart. Counters, setpoints and totalisers. Note how v3 preserved them, if it did.
Step 2: Rebuild the project in Editor v4
Import or recreate the program in OpenPLC Editor v4. Mark variables that must persist as RETAIN explicitly, and enable persistent storage in the project settings, since the built-in retain store is off by default. Configure the Modbus server segments so the generated map matches your HMI tags where you can. Where you cannot, plan the HMI tag updates now rather than at cutover.
Pin versions. Record the Editor version and the runtime tag you will deploy, such as v4.2.3, and deploy exactly that tag rather than latest. The v4.1.0-rc.4 note, in which the Editor blocks uploads to runtimes older than 4.1.0, shows that the Editor and runtime are versioned as a pair.
Step 3: Choose the install path
Docker, with an explicit tag. This is the README’s recommended path. Replace latest with the tag you validated:
docker run -d --name openplc-runtime \
-p 8443:8443 \
--cap-add=SYS_NICE --cap-add=SYS_RESOURCE \
-v openplc-runtime-data:/var/run/runtime \
ghcr.io/autonomy-logic/openplc-runtime:latest
The named volume matters. Inside the container, /var/run/runtime holds the persistent data: the .env with the JWT secret and password pepper, the restapi.db user database, the project snapshot and the retain file. Lose the volume and you lose users, retained values and the stored project.
The one-line container installer. Introduced in v4.2.2, it installs the runtime container plus the bootloader supervisor, so the Editor can change runtime versions without SSH. It detects an existing systemd OpenPLC service and stops and disables it, because both bind port 8443. It records what it displaced so that uninstalling can restore it, and it reuses data in /var/lib/openplc-runtime. The v4.2.2 pull request notes that the vanity installer hostname still needed standing up at release time, and that the raw GitHub URL documented in the README works meanwhile. Take the current URL from the README rather than from a blog post.
Native build. sudo ./install.sh --native builds from source. It is the path the Windows installer and the Dockerfiles use. On native Linux, persistent data lives in /var/lib/openplc-runtime/. The current README lists GCC, CMake 3.28 or newer, Python 3.10 or newer, and root. Older revisions listed lower minimums, so check the README at the tag you deploy. Prebuilt binaries exist for amd64, arm64 and armv7.
Whichever path you choose, give the runtime a real-time-friendly host. The README recommends a dedicated CPU core and minimal background load, and calls a PREEMPT_RT kernel optional but beneficial. Its stated minimum is a 1 GHz single core, 512 MB of RAM and 500 MB of disk. The recommendation is a dual-core 2 GHz CPU and 1 GB of RAM on Ubuntu 22.04 or Debian 12.
Step 4: Provision identity immediately
Right after the runtime first starts, create the administrator with the Editor. Do not leave a fresh runtime reachable on the network with no users. /api/create-user accepts the first account without authentication, so whoever reaches the device first becomes its administrator. Do this on an isolated network, then move the controller to its production VLAN.
Step 5: Bench test, then shadow test
Deploy to a bench unit with the same OS image and CPU architecture as production. Validate these points:
- Every HMI tag reads and writes the right variable. Use a Modbus client to walk the generated map address by address.
- Retained values survive a warm restart. Then re-upload a modified program and check what happens. Expect a cold reset of retained data whenever the program identity changes.
- A deliberately broken upload leaves the controller in the state you expect. The docs disagree on whether the old program stays loaded.
- The watchdog behaviour is acceptable. Deliberately hang a test program and confirm what the outputs do when the process dies.
- The scan statistics (average, maximum, overruns) stay within your requirement under realistic load, including an active debug session and your protocol plugins.
If you can, run v4 in shadow mode alongside the v3 controller, reading the same inputs with outputs disconnected. Compare internal state over at least one full production cycle.
Step 6: Cut over with a rollback path
Schedule the cutover in a maintenance window. Keep the v3 SD card or disk image intact and labelled, because it is your rollback. After cutover, watch the runtime logs through the Editor and the scan statistics for the first shift. Then retire v3 physically rather than leaving it powered on the network. An unpatched, archived web application with upload rights on port 8080 is exactly the kind of asset attackers look for.
Trade-offs, Gotchas and What Goes Wrong
The first user wins. /api/create-user is unauthenticated for the first account. A runtime brought up on a shared network before provisioning can be claimed by anyone who finds it. Always provision in isolation.
Container data can silently go to the wrong place. The v4.2.2 pull request documents a bug found on real hardware. The containerised runtime chose its persistent directory by detecting that it was inside a container, not by checking what was mounted. It wrote a fresh .env and restapi.db inside the container while the mounted restapi.db, project_snapshot/ and retain.bin sat unused. Every version swap would have discarded users, the stored project and retained values while appearing to work. It was fixed by setting OPENPLC_PERSISTENT_DATA_DIR. The lesson for your own deployments is to verify after the first upgrade that users and retained values actually survived. Do not infer it from the volume mount.
No automatic rollback. The bootloader stops and hands the device to recovery when a version change fails. That is safe, but it means an Editor-initiated upgrade on a remote site can leave the PLC stopped until someone intervenes. Schedule upgrades when someone can respond.
Slow links make upgrades long. The same PR measured a 974 MB image pull on a Raspberry Pi-class device at 461 KB/s, taking 59 minutes. The pull happens before the old container stops, so the PLC keeps running during it, but plan the maintenance window for the whole sequence.
Plugins can fail quietly. Since v4.1.3, a plugin that cannot load degrades instead of forcing ERROR. That is good for uptime and bad for detection. Confirm in the logs that every fieldbus plugin started.
Native cycle hooks stretch the scan. cycle_start and cycle_end run inside every scan with the buffer mutex held. A native plugin that does network I/O in a hook adds its latency to every cycle. Keep hook work to buffer copies, and push I/O into the plugin’s own thread.
Retain is conservative. The store keys saved values to an MD5 identity of the program, so uploading any changed program can make it discard them and cold-reset. Record critical retained values before every program upload, not only uploads that change retained declarations.
Retrieve Project leaks metadata. The project name and timestamp go out in unauthenticated discovery replies, and the stored ZIP is neither encrypted nor signed. If your project names reveal customers or processes, rename them, or leave the feature off.
Documentation lags code. The compile-flow doc, README and architecture doc disagree about the toolchain, and even the README’s native-build prerequisites have changed between revisions. When they disagree, trust the code and release notes for your specific tag.
Some of the old model is simply gone. There is no browser UI to fall back on, and DNP3 and EtherNet/IP are absent today. If your operations depend on either, v4 is not yet a drop-in replacement.
Practical Recommendations
For most v3 users the answer is to migrate now and migrate carefully. Every month on v3 is another month on an archived codebase with an upload-capable web interface. The v4.2.x line is the first where retained variables exist, which removes the most common functional blocker. The exception is any controller that depends on DNP3 or EtherNet/IP. Isolate those and plan a gateway or plugin rather than rushing them.
Think of the move in IEC terms too. v4 still runs classic IEC 61131-3 scan-cycle programs. It does not move you towards the event-driven, distributed model of IEC 61499 that we compared in our IEC 61499 vs IEC 61131-3 analysis. Your existing ST and ladder skills carry over. What changes is the operational model around them: tokens, containers, plugins and version pinning.
A short checklist:
- [ ] Recover and version-control all v3 program source before anything else.
- [ ] Inventory protocols. Any DNP3 or EtherNet/IP dependency blocks a direct move.
- [ ] Pin a runtime tag (currently v4.2.3) and a matching Editor version. Never deploy
latestto production. - [ ] Mark
RETAINvariables explicitly and enable project persistent storage. - [ ] Regenerate the Modbus map from the Editor and diff it against HMI and SCADA tags. Check the server port.
- [ ] Provision the admin account on an isolated network.
- [ ] Mount persistent data correctly, and verify that users and retained values survive an upgrade.
- [ ] Soak-test the scan statistics and watchdog behaviour on bench hardware.
- [ ] Keep the v3 image as a rollback, then take v3 off the network once v4 is stable.
Frequently Asked Questions
Is OpenPLC v3 still supported in 2026?
No. The OpenPLC v3 repository states that the project has reached end of life and is no longer maintained. GitHub shows it as archived and read-only since April 4, 2026. It points users to OpenPLC Runtime v4 in the Autonomy-Logic/openplc-runtime repository. Existing v3 installs keep working, but they get no bug or security fixes. Given that v3 exposes a web interface that can upload and start programs, treat any remaining v3 controller as a legacy asset. Isolate it on the network and schedule its migration.
Does OpenPLC Runtime v4 have a web interface?
No. v4 is headless by design. The README explicitly warns against opening https://localhost:8443 in a browser, because unlike v3 there is no web interface there. Port 8443 serves a JSON REST API with JWT authentication, meant for OpenPLC Editor v4 and the Autonomy Edge cloud console. You configure the runtime’s address and credentials in the Editor, then upload, start, stop and debug from there. Integrators can call the API directly for automation, but day-to-day operation happens in the Editor.
How do I run OpenPLC Runtime in Docker?
Pull ghcr.io/autonomy-logic/openplc-runtime and run it with port 8443 published. Add --cap-add=SYS_NICE and --cap-add=SYS_RESOURCE so the scan thread can use real-time scheduling. Mount a named volume at /var/run/runtime for persistent data. Pin a specific release tag in production instead of latest. Since v4.2.2 there is also a one-line container installer that adds a bootloader supervisor on port 8445. It lets the Editor change runtime versions without SSH, and it displaces any systemd OpenPLC service on the same port.
Can I upload a v3 program to OpenPLC v4?
Not directly. v4 expects a ZIP produced by OpenPLC Editor v4, containing generated C/C++ sources that the runtime compiles into a shared library. It does not accept the Structured Text files v3 compiled on the device. Recreate or import your POUs in Editor v4, then upload from the Editor. Keep Editor and runtime versions matched: since v4.1.0-rc.4 the Editor reads the runtime version and blocks STruC++ uploads to runtimes older than 4.1.0.
Does OpenPLC v4 support retain variables?
Yes, from v4.2.0, released September 2, 2026. Retain support covers marshalling of retained values from the compiled program, a plugin-based store and cold reset. It includes a built-in file store that is off by default, so you must enable persistent storage in the project settings. The store keys saved data to an MD5 identity of the program, so uploading a changed program can make it discard retained values and start from initial values. Record critical retained values before any program upload.
Which industrial protocols does OpenPLC v4 support?
On the main branch as of September 2026, the plugin tree includes Modbus TCP master and slave, and OPC UA based on asyncua, as Python plugins. EtherCAT and S7comm are native C/C++ plugins. DNP3 and EtherNet/IP, which v3 had as built-in servers, are not in the v4 plugin tree at the time of writing. Because protocols are plugins listed in plugins.conf, you can also write your own in Python or C, using the documented init, start_loop, stop_loop and cleanup lifecycle.
Further Reading
- OpenPLC v3 architecture and Modbus TCP: the legacy baseline
- CODESYS vs TwinCAT: commercial soft PLC comparison for 2026
- Software-defined manufacturing and virtual PLCs in containers
- IEC 61499 vs IEC 61131-3: distributed control compared
- OpenPLC Runtime v4 repository and README (Autonomy Logic)
- OpenPLC Runtime v4 architecture documentation
- OpenPLC Runtime v4 compilation flow documentation
- OpenPLC Runtime v4 plugin driver documentation
- OpenPLC Runtime v4 releases
- OpenPLC v3 repository with end-of-life notice
- OpenPLC Editor Modbus addressing reference (Autonomy Edge docs)
By Riju — about
