Overview
The Siemens SIMATIC IOT2050 is a rugged industrial IoT gateway built on the ARMv8-A 64-bit (aarch64) architecture. It bridges OT and IT networks in plant environments and ships with a Debian 10 (Buster)-based example image. Industrial automation projects routinely deploy .NET workloads on the IOT2050 — typically ASP.NET Core services for OPC UA aggregation, MQTT bridging, Modbus/TCP masters, and REST gateways to higher-level SCADA and MES systems.
Containerising ASP.NET Core 3.1 for the IOT2050 requires Docker images compiled for ARM64. The Microsoft Container Registry (MCR) at mcr.microsoft.com/dotnet provides tags for every supported architecture, but the tag suffix determines the libc family and the host distribution the image is compatible with. Selecting an Ubuntu focal tag on a Debian host produces runtime failures such as exec /usr/bin/dotnet: no such file or directory or error while loading shared libraries: libc.so.6 when the container starts.
This article documents the correct Debian-based base image tags, a complete multi-stage Dockerfile pattern, runtime configuration, and verification steps for ASP.NET Core 3.1 on the SIMATIC IOT2050.
Hardware and OS Prerequisites
The SIMATIC IOT2050 family ships in two variants. Both expose the same ARMv8-A 64-bit instruction set, so the same container images run on either model.
| Variant | SoC | RAM | Flash | Ethernet | Serial |
|---|---|---|---|---|---|
| IOT2050 | TI AM6528 (Cortex-A53, quad-core) | 1 GB | 4 GB | 2 x GbE | 1 x RS232/485 |
| IOT2050 Advanced | TI AM6548 (Cortex-A53, quad-core + PRU-ICSS) | 2 GB | 16 GB | 2 x GbE | 2 x RS232/485 |
The standard Siemens example image provides:
- Debian 10 (Buster), kernel 4.19
- Docker Engine 19.03 or later with overlay2 storage driver
- systemd as PID 1
- Industrial interfaces: eth0 / eth1 GbE, RS232 / RS485 transceivers, optional Profinet / EtherCAT via PRU-ICSS on the Advanced variant
Verify the host before building:
uname -m
# aarch64
cat /etc/os-release
# PRETTY_NAME="Debian GNU/Linux 10 (buster)"
docker info | grep -i architecture
# Architecture: aarch64
Base Image Selection
Microsoft publishes official ASP.NET Core runtime and .NET SDK images for several host distributions. Each tag embeds the libc family of the chosen OS, and tags are not portable across libc families. The following matrix summarises the options for ASP.NET Core 3.1 on ARM64:
| Tag suffix | Base OS | libc | Compatible with IOT2050 example image? |
|---|---|---|---|
-focal-arm64v8 |
Ubuntu 20.04 LTS | glibc 2.31 | No — Debian host, different library paths |
-bionic-arm64v8 |
Ubuntu 18.04 LTS | glibc 2.27 | No |
-buster-arm64v8 |
Debian 10 | glibc 2.28 | Yes — matches example image |
-bullseye-arm64v8 |
Debian 11 | glibc 2.31 | Yes — Debian family, but newer |
-alpine-arm64v8 |
Alpine 3.13+ | musl libc | Conditional — verify native deps |
For ASP.NET Core 3.1 the supported Debian tag is 3.1-buster-arm64v8. The full repository paths are:
- Runtime:
mcr.microsoft.com/dotnet/aspnet:3.1-buster-arm64v8 - SDK (build stage):
mcr.microsoft.com/dotnet/sdk:3.1-buster-arm64v8
Microsoft rebuilds the images within 12 hours of any base image or runtime update, as documented on the microsoft/dotnet Docker Hub page. Tag semantics and supported architectures are defined in the dotnet-docker README.aspnet.md and the Official .NET Docker images reference on Microsoft Learn.
Multi-Stage Dockerfile
The reference Dockerfile below produces a runtime image small enough to run on the IOT2050's 4 GB flash. The pattern is the Microsoft-recommended multi-stage build: the SDK stage compiles the application, the runtime stage ships only the published artefacts.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/aspnet:3.1-buster-arm64v8 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:3.1-buster-arm64v8 AS build
WORKDIR /src
COPY ["Iot2050Gateway/Iot2050Gateway.csproj", "Iot2050Gateway/"]
RUN dotnet restore "Iot2050Gateway/Iot2050Gateway.csproj"
COPY . .
WORKDIR "/src/Iot2050Gateway"
RUN dotnet publish "Iot2050Gateway.csproj" \
-c Release \
-o /app/publish \
--no-restore \
/p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ARG USERNAME=appuser
RUN groupadd -r ${USERNAME} \
&& useradd -r -g ${USERNAME} ${USERNAME} \
&& chown -R ${USERNAME}:${USERNAME} /app
USER ${USERNAME}
ENTRYPOINT ["dotnet", "Iot2050Gateway.dll"]
Layer-by-layer breakdown
-
base stage — inherits the Debian 10 ASP.NET Core runtime, sets
/appas the working directory, exposes TCP 80/443. - build stage — uses the matching Debian 10 SDK to restore NuGet packages and publish a framework-dependent deployment.
-
final stage — copies only the published artefacts onto the runtime base. The non-root
appuserreduces the attack surface for a device on the shop floor network.
If industrial libraries are required (libncurses5, iputils-ping, an SSH client for remote diagnostics), install them in the base stage and create the supplementary group membership required for serial port access:
FROM mcr.microsoft.com/dotnet/aspnet:3.1-buster-arm64v8 AS base
ARG USERNAME=appuser
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
iputils-ping \
libncurses5 \
openssh-client \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r ${USERNAME} \
&& useradd -r -g ${USERNAME} -G dialout,adm ${USERNAME} \
&& chown -R ${USERNAME}:${USERNAME} /app
USER ${USERNAME}
EXPOSE 80
EXPOSE 443
On Debian 10 the dialout group owns the serial devices /dev/ttyS0 and /dev/ttyS1; adm provides read access to many files in /var/log. Both are required when the gateway process opens RS232 or RS485 ports for Modbus RTU or proprietary serial protocols.
--no-install-recommends flag, the multi-stage build, and the removal of /var/lib/apt/lists/* keep the final image under 220 MB on ARM64. The 4 GB flash variant of the IOT2050 has roughly 2.5 GB usable for application storage, so a 220 MB image plus a 50 MB persisted config volume leaves sufficient headroom for the base OS and two or three additional containers.Building the Image
Build directly on the IOT2050 (recommended) or on any ARM64 build host with Docker 19.03+:
docker build \
-t iot2050/gateway:1.0.0 \
-f Dockerfile \
.
The --platform=linux/arm64 flag is only required when building on a non-ARM64 host with QEMU user-mode emulation installed. On the IOT2050 itself, omit it to use the native builder.
Confirm the resulting image is ARM64:
docker inspect iot2050/gateway:1.0.0 \
--format '{{.Architecture}} {{.Os}}'
# aarch64 linux
Running the Container on the IOT2050
docker run -d \
--name iot2050-gateway \
--restart unless-stopped \
--network host \
--device /dev/ttyS0:/dev/ttyS0 \
--device /dev/ttyS1:/dev/ttyS1 \
-v /etc/iot2050-gateway/appsettings.json:/app/appsettings.json:ro \
iot2050/gateway:1.0.0
Operational notes:
-
--network hostremoves the Docker bridge so the gateway listens directly on the IOT2050's eth0 / eth1. Required if the container must be reachable on the fieldbus subnet without NAT or if the gateway needs to bind to the multicast groups used by PROFINET discovery. -
--devicepasses the RS232 / RS485 ports through. The container's user must be a member ofdialoutfor the device to be openable; this is ensured by theuseradd -G dialoutline in the Dockerfile. - The bind mount for
appsettings.jsonlets operators change the production config without rebuilding the image.
systemd unit (optional)
If the IOT2050 boots into the Siemens example image with systemd as PID 1, register the container as a service for clean dependency ordering and journald log integration:
# /etc/systemd/system/iot2050-gateway.service
[Unit]
Description=ASP.NET Core IoT2050 Gateway
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start -a iot2050-gateway
ExecStop=/usr/bin/docker stop iot2050-gateway
WorkingDirectory=/opt/iot2050-gateway
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now iot2050-gateway.service
journalctl -u iot2050-gateway.service -f
Verification
After the container starts, verify the runtime is healthy.
Process check
docker exec iot2050-gateway ps -ef
# Expect: dotnet Iot2050Gateway.dll running as appuser
Endpoint check
curl -sS http://127.0.0.1:8080/health
# Expect: 200 OK with JSON body containing "status":"Healthy"
Architecture confirmation
docker exec iot2050-gateway uname -m
# aarch64
docker exec iot2050-gateway ldd --version
# ldd (Debian GLIBC) 2.28
The Debian GLIBC 2.28 string confirms the image is the Debian 10 build and is binary-compatible with the IOT2050 host. If ldd reports glibc 2.31 (Ubuntu focal) the wrong tag was used and the container will fail under load as soon as a shared library lookup hits a host path mismatch.
Troubleshooting Matrix
| Symptom | Root cause | Resolution |
|---|---|---|
exec format error on docker run
|
Image built for x86_64 or arm/v7 | Rebuild with --platform=linux/arm64 or use the arm64v8 tag |
exec /usr/bin/dotnet: no such file or directory |
Base image missing for target arch | Confirm tag ends in arm64v8
|
error while loading shared libraries: libc.so.6: cannot open shared object file |
Ubuntu focal base on Debian host | Switch from focal to buster in the FROM lines |
failed to create shim task: OCI runtime create failed |
IOT2050 storage full |
docker system prune -a; ensure ≥ 500 MB free on the overlay2 parent filesystem |
Container exits immediately, dotnet segfaults |
UseAppHost=true produced an apphost binary incompatible with the runtime |
Re-publish with /p:UseAppHost=false
|
permission denied on /dev/ttyS0
|
Container user not in dialout
|
Add dialout to the supplementary groups at image build time |
| ASP.NET Core cannot bind to port 80/443 | Container running as non-root without CAP_NET_BIND_SERVICE
|
Expose 8080/8443 and reverse-proxy, or grant the capability with --cap-add=NET_BIND_SERVICE
|
dotnet restore hangs on slow mirror |
NuGet feed unreachable from IOT2050's air-gapped network | Configure a local NuGet feed or vendor the packages with --source
|
clock_gettime missing in Alpine-based image |
musl libc differs from glibc expectations | Use the Debian buster tag for any binary that uses NTP / time APIs |
Industrial Integration Patterns
Three deployment patterns dominate Siemens automation projects on the IOT2050. Each is reachable through the multi-stage Dockerfile above with no changes to the base image.
1. OPC UA server on the IOT2050
The container hosts an OPC UA server using the Opc.Ua NuGet package from the OPC Foundation. The Debian 10 base provides the OpenSSL 1.1.1 libraries the stack requires. The server binds to opc.tcp://0.0.0.0:4840 and is reachable from TIA Portal, WinCC Unified, and any OPC UA client on the plant network. Push the --network host flag and ensure firewall rules on the fieldbus VLAN allow inbound TCP 4840.
2. MQTT bridge to a cloud or plant broker
ASP.NET Core exposes data from a SIMATIC S7-1500 over MQTT 5 using MQTTnet. The container uses --network host to publish to an internal broker on TCP 1883 (or 8883 for TLS). The TLS handshake uses the system CA bundle installed in the base stage via the ca-certificates package, so private PKI roots only need to be added once to the image.
3. Modbus/TCP master over RS485
A libmodbus P/Invoke layer is wrapped in an IHostedService. The serial port /dev/ttyS1 is passed through with --device, and the user is in dialout as documented above. The Debian 10 base image ships libmodbus5 in the APT repository if the .NET wrapper needs a native library shim.
Security Considerations
-
Non-root user — the
appuserin the reference Dockerfile is a hardened default. Avoid running as root unless a privileged socket or a kernel-bypass transport requires it. -
Read-only root filesystem — add
docker run --read-only --tmpfs /tmpto make the container filesystem immutable. Persist state via a named volume (e.g.-v iot2050-gw-data:/app/data). -
Image provenance — pin the base image by digest:
mcr.microsoft.com/dotnet/aspnet@sha256:…. Find the digest withdocker buildx imagetools inspect mcr.microsoft.com/dotnet/aspnet:3.1-buster-arm64v8. Pinning prevents a base image rebuild from silently changing the libc or CA bundle underneath the application. -
Vulnerability scanning — run
trivy image iot2050/gateway:1.0.0before deployment. Debian 10 is in LTS support until June 2024 for security updates; plan a migration to Debian 11 / .NET 6+ for new projects. -
Field network isolation — the IOT2050 should sit on a dedicated OT VLAN. The container should not be reachable from the corporate network without an explicit jump host or VPN. Disable Docker's iptables management with
--iptables=falseif the host firewall is centrally managed by the plant network team. -
Secret management — load connection strings, certificates, and broker credentials from environment variables or Docker secrets rather than baking them into the image. The Siemens example image supports the standard Docker secrets path at
/run/secrets/<name>.
Upgrading to .NET 6 or .NET 8
.NET 3.1 reached end of support on 13 December 2022. For new projects on the IOT2050, target .NET 6 (LTS until November 2024) or .NET 8 (LTS until November 2026). The base image tag for Debian 11 is:
FROM mcr.microsoft.com/dotnet/aspnet:6.0-bullseye-arm64v8 AS base
FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-arm64v8 AS build
bullseye base image as soon as the field device is next serviced. Failure to do so leaves the container exposed to unpatched runtime CVEs, which on an industrial gateway directly facing the fieldbus is an unacceptable risk.Update the target framework in the project file from <TargetFramework>netcoreapp3.1</TargetFramework> to <TargetFramework>net6.0</TargetFramework> (or net8.0), rebuild the image, and re-test against the verification procedure above. The OPC UA, MQTT, and Modbus NuGet packages used in the integration patterns are all maintained on .NET 6 / 8 and require no source changes beyond the TFM bump.
FAQ
Which Microsoft base image tag is correct for the SIMATIC IOT2050?
Use the Debian 10 (Buster) tag: mcr.microsoft.com/dotnet/aspnet:3.1-buster-arm64v8 for the runtime and mcr.microsoft.com/dotnet/sdk:3.1-buster-arm64v8 for the build stage. The IOT2050 example image is Debian 10-based, so the Ubuntu focal tag will fail with shared library errors.
Why does the -focal-arm64v8 tag fail on the IOT2050?
It ships glibc 2.31 from Ubuntu 20.04. The IOT2050 example image is Debian 10 (glibc 2.28). Container images are not portable across libc families, so the resulting container fails to start with no such file or directory or error while loading shared libraries.
Can I use Alpine or other musl-based images on the IOT2050?
Yes, 3.1-alpine-arm64v8 runs on Debian hosts because musl is statically linked. It produces a much smaller image (≈ 100 MB), but some industrial libraries — notably older libmodbus binaries and certain vendor-supplied P/Invoke wrappers — link against glibc and will not load without shimming.
Do I need QEMU to build ARM64 images on an x86 workstation?
Yes, unless you build directly on the IOT2050. Use docker buildx create --use --name arm64 --driver docker-container --platform linux/arm64 and pass --platform=linux/arm64 to docker build. The IOT2050's native builder is faster and avoids emulation bugs in native dependencies such as libmodbus or OPC UA crypto libraries.
How do I grant the container access to /dev/ttyS0 for Modbus RTU?
Run with --device /dev/ttyS0:/dev/ttyS0 and ensure the container's user is in the dialout group. In the Dockerfile: useradd -G dialout appuser or usermod -aG dialout ${USERNAME}. Verify with docker exec iot2050-gateway ls -l /dev/ttyS0 — the group owner must be dialout for the open() call to succeed.