# Migrating `openplc-runtime` to another Docker host (air-gapped) > [!NOTE] > **COMPLETED 2026-08-19. This is the as-built record of the migration, kept as a > runbook for repeating it.** The PLC now runs on `yau-sls-poc-lin001` > (`10.0.0.17`) and CI Server on `yau-poc-cicore1` (`10.0.0.21`) polls it live. > The retired source host `dev-ubuntu` is no longer part of this project. > > **Part E was not executed as written.** The service was deployed as its own > compose file on its own network rather than appended to the shared stack — see > the "What was actually done" note in Part E. The as-built configuration is > `openplc-compose.yml` and `openplc-container.md`. Source: `dev-ubuntu` (SSH, now retired) · Destination: `yau-sls-poc-lin001`, Portainer UI + SSH · both x86-64. Written 2026-08-18 against the live container. Why `docker commit` and not `docker pull` on the far side: the compiled PLC program lives in the container's **writable layer** (`/workdir/build/libplc_*.so`, `/workdir/core/generated/`), not in the volume. Only a commit carries it. Two artifacts move: - `openplc-image.tar.gz` — committed image (~400–500 MB gz, 1.08 GB virtual) - `openplc-vol.tar.gz` — the 20 KB volume (`restapi.db` = users + program record, `.env` = JWT secret) --- ## PART A — On the SOURCE host (SSH: `ssh dev-ubuntu`) — ✅ DONE 2026-08-19 Executed in the order A1 → A2 → A4 → A5 → A3 → A6 (volume tar and restart moved ahead of the image save) to cut PLC downtime to ~10 s. The commit at A2 has already frozen the layer, so the artifacts are identical either way. **Result — files on `dev-ubuntu:/home/dev-admin/`:** | File | Size | SHA-256 | |---|---|---| | `openplc-image.tar.gz` | 354 MB | `cbf2d2512d75acb5cce5640213d609383852f087bff6f4614397521ae7347d83` | | `openplc-vol.tar.gz` | 746 B (mode 0600) | `a5a5011335a37f8f01fd0cae57bd8e135ec2e80fced30b723d8d08662d636628` | Verified: committed image contains `/workdir/build/libplc_1786668930820554523.so` and `/workdir/core/generated/`; volume tarball contains `.env` + `restapi.db`; source PLC restarted clean — `MODBUS_SLAVE Server listening on 0.0.0.0:502`, 69 located vars, reachable from Windows. **A1. Stop the container** — for a consistent `restapi.db` snapshot (SQLite). The PLC stops controlling; expected. docker stop openplc-runtime **A2. Commit the container to an image** — freezes the writable layer (your compiled program). docker commit openplc-runtime openplc-runtime-migrated:v4.1.10 **A3. Save the image to a file** — one portable file. Takes 1–3 min. cd ~ && docker save openplc-runtime-migrated:v4.1.10 | gzip > openplc-image.tar.gz **A4. Back up the volume.** `socket ignored` warnings are normal — runtime sockets, recreated on start. docker run --rm -v openplc-runtime-data:/data -v ~:/backup alpine \ tar czf /backup/openplc-vol.tar.gz -C /data . **A5. Restart the source container** — leaves the source working. docker start openplc-runtime **A6. Record checksums + sizes.** Write the two hashes down; you verify them on the far side. cd ~ && ls -lh openplc-image.tar.gz openplc-vol.tar.gz && sha256sum openplc-*.tar.gz --- ## PART B — Transfer (from your Windows PC, PowerShell) **B1. Pull down from source** scp dev-ubuntu:~/openplc-image.tar.gz dev-ubuntu:~/openplc-vol.tar.gz C:\Temp\ **B2. Push up to destination.** Use a dedicated subfolder — D3 bind-mounts this directory into a container, and mounting the whole home directory would expose `.ssh` and everything else to it. Not `/tmp`: cleared on reboot. ssh @ "mkdir -p ~/openplc-migration" scp C:\Temp\openplc-image.tar.gz C:\Temp\openplc-vol.tar.gz @:~/openplc-migration/ Portainer's *Images → Import* upload also works for the image, but a 500 MB browser upload is failure-prone. Use scp. --- ## PART C — Pre-flight on the DESTINATION (SSH) — protects the existing 20 containers Run all of these BEFORE changing anything. Every one must come back clean. **C1. Verify the files arrived intact** — must match A6. If not, re-transfer. cd ~/openplc-migration && sha256sum openplc-image.tar.gz openplc-vol.tar.gz **C2. Architecture + disk space** — `uname -m` must print `x86_64`; need ≥ 3 GB free. uname -m df -h /var/lib/docker **C3. Name collisions — all three must return NOTHING** docker ps -a --format '{{.Names}}' | grep -x openplc-runtime docker volume ls --format '{{.Name}}' | grep -x azureuser-openplc-runtime-data docker images --format '{{.Repository}}' | grep -x openplc-runtime-migrated If any hits, stop and rename — see Appendix 1. **C4. Port 502 free — must return NOTHING** sudo ss -lntp | grep -w 502 docker ps --format '{{.Names}} {{.Ports}}' | grep -w 502 If taken, pick another host port — see Appendix 1. --- ## PART D — Load image + restore data (DESTINATION, SSH) **D1. Load the image.** Expect `Loaded image: openplc-runtime-migrated:v4.1.10`. The existing 25 images are untouched. docker load -i ~/openplc-migration/openplc-image.tar.gz **D2. Create the volume** docker volume create azureuser-openplc-runtime-data **D3. Restore the data into it** docker run --rm -v azureuser-openplc-runtime-data:/data -v ~/openplc-migration:/backup alpine \ tar xzf /backup/openplc-vol.tar.gz -C /data **D4. Confirm the restore.** Expect `.env` and `restapi.db`. Sockets are absent — correct, they get recreated. docker run --rm -v azureuser-openplc-runtime-data:/data alpine ls -la /data --- ## PART E — Deploy the service on the destination > [!IMPORTANT] > **What was actually done (2026-08-19), and why it differs from E1–E6 below.** > Rather than appending the service to the host's existing shared stack, it was > deployed from a **separate compose file**, `~/openplc-compose.yml`, on its > **own network** `openplc-net`. This is safer and is the recommended route: > nothing else on the host is touched, there is no shared `volumes:`/`networks:` > block to merge, rollback is `docker compose -f ~/openplc-compose.yml down`, and > the PLC is isolated from the other ~20 containers. > > Two further differences from the draft below: ports are published on > **`10.0.0.17:502` and `10.0.0.17:8443`** — the LAN address, not `0.0.0.0` — and > `ufw` is **inactive** on this host (F6 does not apply; the Azure NSG governs). > > The live file is copied verbatim to `openplc-compose.yml`. > E1–E6 below describe the shared-stack alternative and are retained for a > destination host where a separate file is not an option. ### Alternative — appending to an existing shared stack (NOT the route taken) The existing stack owns ~20 running containers. Updating it runs `docker compose up -d` over the whole file. Compose only recreates services whose config hash changed, so adding one service normally leaves the rest running — but follow E1 and E5 exactly, they are what keeps that true. **E0. Confirm the stack is editable.** Portainer → **Stacks** → click the stack. If it shows a **Git repository** section (with *Pull and redeploy*) instead of a *Web editor*, STOP — the change must be committed to that git repo instead; a UI edit gets reverted on the next sync. **E1. Back up the current stack file.** Open the stack → **Editor** tab → select all → copy → paste into a local file (e.g. `C:\Temp\dest-stack-backup.yml`). This is your rollback. Do not skip it. **E2. Note what is running now**, so you can prove nothing else restarted: docker ps --format '{{.Names}}\t{{.Status}}' | sort > ~/before.txt **E3. Append the service.** In the **Editor**, add this under the existing top-level `services:` key, at the same indent as the sibling services: ```yaml openplc-runtime: image: openplc-runtime-migrated:v4.1.10 container_name: openplc-runtime restart: unless-stopped cap_add: - SYS_NICE - SYS_RESOURCE ports: - ":502:502" - ":8443:8443" volumes: - azureuser-openplc-runtime-data:/var/run/runtime logging: driver: json-file options: max-size: "10m" max-file: "3" ``` `cap_add` is required for the runtime's real-time scheduling. `` — the destination host's LAN address (here `10.0.0.17`), so the OpenPLC Editor and CI Server on **`yau-poc-cicore1` (10.0.0.21)** can reach it. Binding the specific NIC rather than `0.0.0.0` keeps **both** 502 and 8443 off every other interface. On a host with a public IP this is the only thing keeping unauthenticated Modbus off the internet — never publish on `0.0.0.0`. **E4. Merge the volume declaration.** The file already has a top-level `volumes:` block — add this entry INSIDE it. Do not add a second `volumes:` key; a duplicate top-level key is a YAML error or silently discards one block. ```yaml azureuser-openplc-runtime-data: external: true ``` `external: true` is load-bearing: without it Compose creates a NEW empty volume named `azureuser_azureuser-openplc-runtime-data` and your restored data is ignored. **E5. Deploy.** Click **Update the stack**. In the confirmation dialog: - **Re-pull image — leave OFF.** Turning it on re-pulls every service in the stack; anything on a moving tag gets recreated. - **Prune services — leave OFF.** **E6. Confirm nothing else moved.** docker ps --format '{{.Names}}\t{{.Status}}' | sort > ~/after.txt diff ~/before.txt ~/after.txt The only expected difference is the new `openplc-runtime` line. If other services show fresh uptimes, they were recreated — see Rollback in Part G. ### Note: shared stack network The service joins the existing stack's default network, so it can reach — and be reached by — the other ~20 containers by service name. If you want it isolated, give it its own network instead: ```yaml networks: - openplc-net ``` plus a top-level `openplc-net:` entry merged into the existing `networks:` block. Port publishing to the host works either way. --- ## PART F — Verify **F1.** Portainer → **Containers** → `openplc-runtime` shows **running**, green. **F2. Logs** — Portainer container view → **Logs**, or by SSH: docker logs --tail 50 openplc-runtime Look for the runtime starting and the `modbus_slave` plugin loading. No repeating crash loop. **F3. Port is listening** (SSH on destination) sudo ss -lntp | grep -w 502 **F4. The PLC program came across** (SSH on destination) — a `libplc_*.so` must be present. docker exec openplc-runtime ls -la /workdir/build/ | grep libplc **F5. Modbus read from your Windows PC** — the real acceptance test. Must match the register map, same as against the old host. cd C:\Claude\wrps-demo-kit\05-tests python verify_modbus.py --host --port 502 --unit 1 **F6. Firewall on the destination.** Check current state first. sudo ufw status On `yau-sls-poc-lin001` this returns **`Status: inactive`** — the host has no firewall and inbound filtering is entirely the **Azure NSG**. The `ufw` commands below were therefore **not applied** and are kept only for a destination host that does run `ufw`. Modbus, for CI Server — only needed if F5 times out: sudo ufw allow from /24 to any port 502 proto tcp REST API, for the OpenPLC Editor. Restrict to the single Editor host, not the subnet — 8443 is the control channel (upload, start/stop the program) and JWT auth is the only thing in front of it: sudo ufw allow from 10.0.0.21 to any port 8443 proto tcp **F7. REST API reachable from the Editor host (run on 10.0.0.21).** curl -k https://:8443/api/login -X POST \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":""}' `-k` is required — the runtime serves a self-signed cert, so the Editor will show a trust warning on first connect too. A JWT in the response means the path is open. --- ## PART G — Afterwards - **CI Server:** ✅ done — `yau-poc-cicore1` (`10.0.0.21`) polls `10.0.0.17:502` (`04-scada/` — the Modbus client config is the one place a literal IP is used). - **Do not leave both PLCs answering on 502** with the same tags. ✅ done — the source `dev-ubuntu` runtime was stopped and that host is retired. - **Clean up transfer files on both hosts:** `rm -rf ~/openplc-migration`. `openplc-vol.tar.gz` contains the JWT secret — do not leave it lying around, and never commit either file to git. ### Rollback Do NOT delete the stack — it owns the other ~20 containers. 1. Portainer → **Stacks** → the stack → **Editor** → paste back the E1 backup (`C:\Temp\dest-stack-backup.yml`), replacing the whole file. 2. **Update the stack**, again with *Re-pull image* and *Prune services* OFF. This removes the `openplc-runtime` service and leaves the rest as they were. 3. Then on the destination host: docker rm -f openplc-runtime 2>/dev/null docker volume rm azureuser-openplc-runtime-data docker rmi openplc-runtime-migrated:v4.1.10 4. Firewall, if F6 was applied: sudo ufw delete allow from 10.0.0.21 to any port 8443 proto tcp The destination is back to its prior state. The source host was never modified beyond a stop/start. --- ## Appendix 1 — If a pre-flight check (C3/C4) found a collision - **Container name taken** → change `container_name:` to `wrps-openplc-runtime`. - **Volume name taken** → use a new name in D2/D3 and in the compose file, e.g. `wrps-openplc-data`. The container path `/var/run/runtime` must NOT change. - **Image name taken** → retag after D1: `docker tag openplc-runtime-migrated:v4.1.10 wrps/openplc-runtime:v4.1.10` - **Port 502 taken** → publish elsewhere, e.g. `"5502:502"`, and point CI Server's Modbus client at port 5502. The container-side `502` must not change. - **Port 8443 taken** on the destination → publish elsewhere, e.g. `":9443:8443"`, and point the OpenPLC Editor at port 9443. The container-side `8443` must not change. Add this to the C4 pre-flight: sudo ss -lntp | grep -w 8443 ## Appendix 2 — Login credentials The runtime's `admin` password and JWT secret travel inside `restapi.db` / `.env`, so the same credentials work on the destination. Nothing to reconfigure. See `02-environment/secrets.local.md`.