02-environment merges the old 02-env and 03-containers - both answered the
same question, 'where does this run and how do I reach it', and were split
for no reason.
README.md the two hosts, access, ports, secrets, health
checks, known issues
YAU_Linux_Host_Onboarding.md the Linux host's own brief, copied with a
provenance banner: owned by the host owner, not
maintained here, and its 'save this as CLAUDE.md'
instruction does not apply in this repo
openplc-container.md the container as read from the running host
openplc-compose.yml verbatim copy of the live file
MIGRATION.md moving the container - as-built plus runbook
secrets.local.md.template the shape of the git-ignored secrets file
The README leads with the fact that shapes every decision on that machine:
yau-sls-poc-lin001 is a SHARED, LIVE host running ~28 containers for
several projects behind Caddy and Authelia, and this project owns exactly
one of them. Never restart Caddy or Authelia, never publish on 0.0.0.0,
never put growing data on the 62 GB root disk.
Records four known issues rather than leaving them in anyone's head: the
100 ms scan overruns, the 354 MB migration tarballs still sitting on the
host with the JWT secret inside one of them, the runtime image existing in
no registry, and the missing Editor toolchain.
Not carried across: Host_Documentation.md. ~400 lines on ChirpStack,
Forgejo, EQP licensing and the Telegraf fleet - none of it WRPS, owned
elsewhere, and a stale copy here would be worse than a pointer.
359 lines
14 KiB
Markdown
359 lines
14 KiB
Markdown
# 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 <user>@<dest-host> "mkdir -p ~/openplc-migration"
|
||
scp C:\Temp\openplc-image.tar.gz C:\Temp\openplc-vol.tar.gz <user>@<dest-host>:~/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:
|
||
- "<dest-lan-ip>:502:502"
|
||
- "<dest-lan-ip>: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.
|
||
`<dest-lan-ip>` — 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 <dest-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 <SCADA-subnet>/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://<dest-lan-ip>:8443/api/login -X POST \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"username":"admin","password":"<see 02-environment/secrets.local.md>"}'
|
||
|
||
`-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.
|
||
`"<dest-lan-ip>: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`.
|