SIMATIC IOT2050 ARM64: Building ASP.NET Docker Images

David Krause11 min read
Other TopicSiemensTutorial / How-to
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

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 /app as 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 appuser reduces 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.

Image size: the --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 host removes 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.
  • --device passes the RS232 / RS485 ports through. The container's user must be a member of dialout for the device to be openable; this is ensured by the useradd -G dialout line in the Dockerfile.
  • The bind mount for appsettings.json lets 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 appuser in 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 /tmp to 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 with docker 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.0 before 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=false if 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
End-of-life warning: .NET Core 3.1 no longer receives security patches. Existing deployments on the SIMATIC IOT2050 should be migrated to .NET 6 or .NET 8 on the 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.

Back to blog