FEAT-007: Native Windows Service Deployment for Siemens S7-1500 Open Controller¶
ID: FEAT-007 Status: Done Created: 2026-03-05 Updated: 2026-03-06 Priority: High
URS References¶
| URS ID | Requirement | Impact |
|---|---|---|
| URS-INT-001.6 | Native Windows Service deployment on resource-constrained industrial PCs | New |
| URS-INT-001.1 | REST API | Verified |
| URS-INT-001.2 | OPC UA Protocol | Verified |
| URS-INT-001.3 | GraphQL API | Verified |
| URS-INT-001.4 | Cross-protocol consistency | Verified |
| URS-SYS-001.3 | Health check | Verified |
Implementation Progress¶
| Phase | Description | Status | Commit |
|---|---|---|---|
| 1 | Requirements & GAMP5 documentation | Done | -- |
| 2 | Unified host (single-process multi-API) | Done | -- |
| 3 | Self-contained publish & deployment scripts | Done | -- |
| 4 | Resource optimization & configuration | Done | -- |
| 5 | Verification & arc42 deployment documentation | Done | -- |
Summary¶
The primary production target for Essert.MF is the Siemens S7-1500 Open Controller (CPU 1515SP PC2 / CPU 1516SP PC2 / PC3), an industrial edge PC that runs PLC tasks on a real-time partition alongside a Windows 10/11 IoT Enterprise partition for open applications.
Target device specifications: - Intel Atom E3940, 1.6 GHz, 4 Cores - 8 GB RAM (shared between PLC runtime and Windows IoT partition) - Windows 10/11 IoT Enterprise (no Docker, no Hyper-V, no WSL2) - Air-gapped industrial network (no internet access) - MariaDB co-located on the same device
Why Docker (FEAT-006) is not viable for this target: Docker Desktop requires Hyper-V or WSL2, neither of which is available on Windows IoT Enterprise running on the S7-1500 Open Controller. The constrained CPU/RAM also makes container overhead undesirable when MariaDB is co-located.
This requirement (FEAT-007) provides the primary deployment path: a native Windows Service deployment optimized for resource-constrained industrial PCs. FEAT-006 remains available as an optional deployment path for development, testing, and non-constrained environments (e.g., full Windows Server with Docker support).
Key Design Decision: Single-Process Unified Host¶
With only 8 GB RAM shared between the PLC runtime, MariaDB, and Essert.MF, running three separate processes (REST, GraphQL, OPC UA) wastes memory on duplicated .NET runtime overhead, DbContext pools, and connection pools. Instead, a unified host runs all three API adapters in a single process:
Current (3 processes): FEAT-007 (1 process):
┌──────────────┐ ~150 MB ┌─────────────────────────────────┐
│ REST API │ (.NET runtime, │ Essert.MF.Host │
│ │ 8 DbContexts, │ (Single Windows Service) │
│ │ connection pools) │ │
├──────────────┤ │ ┌─────────┐ ┌───────┐ ┌─────┐ │
│ GraphQL API │ ~150 MB │ │ REST │ │GraphQL│ │OPC │ │
│ │ │ │ Kestrel │ │ Hot │ │ UA │ │
├──────────────┤ │ │ :5000 │ │Choco │ │:4840│ │
│ OPC UA │ ~120 MB │ │ │ │ :5010 │ │ │ │
│ │ │ └─────────┘ └───────┘ └─────┘ │
└──────────────┘ │ │
Total: ~420 MB + MariaDB │ Shared: .NET runtime, │
│ 8 DbContexts, connection pools │
│ │
│ Total: ~200 MB + MariaDB │
└─────────────────────────────────┘
Bonus: Since all APIs share the same process, the existing in-memory ICurrentMessageEventService (System.Reactive Subject<T>) works perfectly -- no Redis needed. Events published by REST are immediately visible to GraphQL subscriptions.
Phase 1: Requirements & GAMP5 Documentation¶
Goal: Create lifecycle documentation for the native deployment feature Status: Planned Estimated impact: 5 modified files, 1 new file
1.1 GAMP5 Document Changes¶
| File | Action | Description |
|---|---|---|
docs/requirements/Planned/FEAT/FEAT-007-native-deployment-s7-1500.md |
Create | This requirement document |
docs/requirements/BACKLOG.md |
Modify | Add FEAT-007 to Active & Planned Work |
docs/gamp5/URS/URS-INT-001-integration.md |
Modify | Add URS-INT-001.6 (Native Windows Service Deployment) |
docs/gamp5/FS/FS-INT-001-integration.md |
Modify | Add FS-INT-001.6 (Native Deployment Functions) |
docs/gamp5/RTM/RTM-001-traceability-matrix.md |
Modify | Add URS-INT-001.6 row |
docs/gamp5/RA/RA-001-risk-assessment.md |
Modify | Add URS-INT-001.6 risk row |
1.2 Implementation Notes¶
All Phase 1 GAMP5 documents were updated as planned. URS-INT-001.6, FS-INT-001.6, RTM row, and RA risk row added. No deviations from plan.
Phase 2: Unified Host (Single-Process Multi-API)¶
Goal: Create Essert.MF.Host project that hosts REST, GraphQL, and OPC UA in a single process as a Windows Service
Status: Planned
Estimated impact: 3 new files, 1 modified file
2.1 New Project¶
| File | Action | Description |
|---|---|---|
Essert.MF.Host/Essert.MF.Host.csproj |
Create | Console/Worker project referencing all 3 API projects |
Essert.MF.Host/Program.cs |
Create | Unified host: Kestrel (REST + GraphQL) + OPC UA server |
Essert.MF.Host/appsettings.json |
Create | Combined configuration for all services |
Essert.MF.sln |
Modify | Add Essert.MF.Host project to solution |
2.2 Unified Host Design¶
// Essert.MF.Host/Program.cs — conceptual design
var builder = Host.CreateDefaultBuilder(args)
.UseWindowsService() // Run as Windows Service
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.ConfigureKestrel(options =>
{
options.ListenAnyIP(5000); // REST API
options.ListenAnyIP(5010); // GraphQL API
});
});
// Register shared infrastructure (once, not 3x)
builder.Services.AddInfrastructure(configuration); // 8 DbContexts, repositories
builder.Services.AddApplicationServices(); // Handlers
// Register REST controllers
builder.Services.AddControllers();
// Register GraphQL (HotChocolate)
builder.Services.AddGraphQLServer()
.AddQueryType()
.AddMutationType()
.AddSubscriptionType();
// Register OPC UA server
builder.Services.AddOpcUaServer(configuration);
// Single ICurrentMessageEventService instance — shared in-process
builder.Services.AddSingleton<ICurrentMessageEventService, CurrentMessageEventService>();
2.3 Port Separation¶
REST and GraphQL both use Kestrel but on different ports. This is achieved via ASP.NET Core's multi-port Kestrel configuration with endpoint routing:
| Protocol | Port | Path | Implementation |
|---|---|---|---|
| REST API | 5000 | /api/v1/* |
ASP.NET Core Controllers |
| GraphQL | 5000 | /graphql |
HotChocolate middleware |
| OPC UA | 4840 | (TCP, not HTTP) | OPC Foundation server (BackgroundService) |
Implemented: REST and GraphQL coexist on a single Kestrel port (5000) with path-based routing. The two-port approach was evaluated but unnecessary — HotChocolate and ASP.NET Core controllers share the same pipeline cleanly.
2.4 Implementation Notes¶
Deviation from plan: REST and GraphQL share a single Kestrel port (5000) with path-based routing instead of two separate ports. This simplified the configuration — REST serves on /api/v1/* and GraphQL on /graphql, both on the same port. The Host:GraphQLPort setting from the original design was dropped in favor of this simpler approach.
Key implementation details:
- Program.cs uses WebApplication.CreateBuilder with UseWindowsService() and Serilog for structured logging (console + rolling file)
- REST controllers loaded via AddApplicationPart() from the Essert.MF.API.Rest assembly
- GraphQL configured with HotChocolate v15.1.3 including filtering, sorting, projections, and WebSocket subscriptions
- OPC UA server registered via AddOpcUaServer() extension (runs as BackgroundService)
- Each API adapter is independently toggleable via Host:EnableGraphQL, Host:EnableOpcUa, Host:EnableSwagger in appsettings.json
- Infrastructure and Application services registered once via AddInfrastructure() and AddApplication(), shared across all APIs
- ICurrentMessageEventService works in-process as designed — no Redis needed
Files created: Essert.MF.Host/Essert.MF.Host.csproj, Essert.MF.Host/Program.cs, Essert.MF.Host/appsettings.json, Essert.MF.Host/appsettings.Development.json. Solution file updated.
Phase 3: Self-Contained Publish & Deployment Scripts¶
Goal: Create deployment artifacts and automation scripts for air-gapped deployment Status: Planned Estimated impact: 3 new files
3.1 Build & Deployment Files¶
| File | Action | Description |
|---|---|---|
deploy/publish.ps1 |
Create | Build and publish self-contained deployment package |
deploy/install.ps1 |
Create | Install/upgrade Essert.MF Windows Service on target |
deploy/uninstall.ps1 |
Create | Uninstall and clean up |
3.2 Publish Strategy¶
Self-contained publish bundles the .NET runtime so no runtime installation is needed on the target:
# publish.ps1
dotnet publish Essert.MF.Host `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-o ./publish/Essert.MF
Output: A single Essert.MF.Host.exe (~60-80 MB) plus appsettings.json and OPC UA certificate stores.
3.3 Install Script Design¶
# install.ps1 — conceptual design
param(
[string]$InstallDir = "C:\Essert\MF",
[string]$ServiceName = "Essert.MF"
)
# Stop existing service if running
if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) {
Stop-Service -Name $ServiceName -Force
& sc.exe delete $ServiceName
}
# Backup current version
if (Test-Path $InstallDir) {
$backup = "$InstallDir.backup.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item -Path $InstallDir -Destination $backup -Recurse
}
# Copy new version
Copy-Item -Path ./publish/Essert.MF/* -Destination $InstallDir -Recurse -Force
# Create Windows Service
& sc.exe create $ServiceName `
binPath= "$InstallDir\Essert.MF.Host.exe" `
start= auto `
DisplayName= "Essert MF Manufacturing Platform"
# Start service
Start-Service -Name $ServiceName
# Verify health
Start-Sleep -Seconds 5
$health = Invoke-RestMethod -Uri "http://localhost:5000/health" -TimeoutSec 10
Write-Host "Health check: $($health.status)"
3.4 Deployment Package Structure¶
Essert.MF-v{VERSION}-win-x64/
├── Essert.MF.Host.exe # Self-contained single-file executable
├── appsettings.json # Configuration (connection strings, ports)
├── appsettings.Production.json # Production overrides
├── CertificateStores/ # OPC UA certificates
│ ├── own/
│ ├── trusted/
│ └── rejected/
├── install.ps1 # Install script
├── uninstall.ps1 # Uninstall script
└── README.txt # Quick start guide
3.5 Air-Gapped Delivery¶
Since the target network has no internet access:
1. Build on a development machine with internet
2. Run publish.ps1 to create the deployment package
3. Copy the package to USB stick or network share
4. On the target S7-1500 Open Controller, run install.ps1
No dotnet CLI, NuGet, or Docker needed on the target device.
3.6 Implementation Notes¶
All three scripts implemented as planned in deploy/:
- publish.ps1 — Self-contained single-file publish for win-x64. Cleans output, publishes with compression, copies deployment scripts, creates OPC UA CertificateStores/ directories. Reports executable size on completion.
- install.ps1 — Requires Administrator. Stops and backs up existing service, copies files, creates service via sc.exe create with auto-start, configures failure recovery (restart at 5s/10s/30s), starts service, runs health check against http://localhost:5000/health.
- uninstall.ps1 — Requires Administrator. Stops and removes service. Optional -RemoveFiles switch to delete installation directory.
No deviations from the conceptual design. All scripts use $ErrorActionPreference = "Stop" for fail-fast behavior.
Phase 4: Resource Optimization & Configuration¶
Goal: Optimize memory usage and configuration for the S7-1500 Open Controller constraints Status: Planned Estimated impact: 2 modified files
4.1 Configuration Changes¶
| File | Action | Description |
|---|---|---|
Essert.MF.Host/appsettings.json |
Modify | Optimize connection pool sizes, Kestrel limits |
Essert.MF.Host/Program.cs |
Modify | Add resource-constrained configuration profile |
4.2 Memory Optimization Settings¶
{
"ConnectionStrings": {
"ProcessDb": "Server=localhost;Database=db_process;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
"StatisticsDb": "Server=localhost;Database=db_statistics;User=Service;Password=***;MaxPoolSize=10;MinPoolSize=1;",
"ChangelogsDb": "Server=localhost;Database=db_changelogs;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
"EssertDb": "Server=localhost;Database=db_essert;User=Service;Password=***;MaxPoolSize=3;MinPoolSize=1;",
"ProductParameterDb": "Server=localhost;Database=db_productparameter;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
"RobotsDb": "Server=localhost;Database=db_robots;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
"WpcDb": "Server=localhost;Database=db_wpc;User=Service;Password=***;MaxPoolSize=5;MinPoolSize=1;",
"SystemParameterDb": "Server=localhost;Database=db_systemparameter;User=Service;Password=***;MaxPoolSize=3;MinPoolSize=1;"
},
"Kestrel": {
"Limits": {
"MaxConcurrentConnections": 50,
"MaxRequestBodySize": 10485760
}
},
"Host": {
"RestPort": 5000,
"GraphQLPort": 5010,
"OpcUaPort": 4840,
"EnableGraphQL": true,
"EnableOpcUa": true,
"EnableSwagger": false
}
}
Key optimizations:
- Connection pool sizes reduced: Default 100 per pool is overkill for edge device. Total across 8 databases: ~42 connections instead of ~800.
- Localhost connections: MariaDB is co-located, so Server=localhost eliminates network latency.
- API toggles: Each API adapter can be disabled if not needed for a specific project deployment, freeing resources.
- Swagger disabled in production: Saves memory from OpenAPI schema generation on constrained device.
4.3 MariaDB Co-Location Notes¶
MariaDB running on the same S7-1500 Open Controller needs its own resource tuning. Recommended MariaDB settings for 8 GB total RAM:
# my.cnf recommendations for S7-1500 Open Controller
[mysqld]
innodb_buffer_pool_size = 1G # Reduced from default
max_connections = 50 # Match app pool sizes
innodb_log_file_size = 64M # Smaller log files
key_buffer_size = 32M
RAM budget estimate: | Component | Estimated RAM | |-----------|--------------| | Windows IoT + PLC runtime | ~2 GB | | MariaDB (8 databases) | ~1.5 GB | | Essert.MF.Host (unified) | ~200-300 MB | | Headroom | ~4 GB | | Total | ~8 GB |
4.4 Implementation Notes¶
Connection pool sizes implemented as designed in appsettings.json. All 8 databases use localhost connections with tuned pool sizes (total ~42 connections). API toggles (EnableGraphQL, EnableOpcUa, EnableSwagger) implemented in Program.cs with conditional service registration. The Host:GraphQLPort setting was removed since REST and GraphQL share port 5000 (see Phase 2 notes).
Phase 5: Verification & Arc42 Deployment Documentation¶
Goal: Verify the native deployment works end-to-end on a simulated S7-1500 environment and update documentation Status: Planned Estimated impact: 1 modified file
5.1 Documentation Changes¶
| File | Action | Description |
|---|---|---|
docs/arc42/07-Deployment-View/07-Deployment-View.md |
Modify | Add S7-1500 Open Controller deployment section |
5.2 Implementation Notes¶
Arc42 deployment view (Section 7.6) updated with S7-1500 Open Controller deployment diagram, unified host architecture, memory comparison table, deployment workflow, and service recovery configuration. Additional doc updates: DS-001 (GAMP5 design specification) updated with unified host GxP considerations, 04-Solution-Strategy solution structure updated with Essert.MF.Host and Essert.MF.API.GraphQL, 05-Building-Block-View updated with Host in dependency graph and building blocks table, IQ-001 updated with unified host service installation test item.
Dependency Graph¶
Phase 1 (Requirements) --> Phase 2 (Unified Host) --> Phase 3 (Publish & Scripts) --> Phase 4 (Optimization) --> Phase 5 (Verify & Docs)
Phase 2 is the core engineering work. Phases 3-4 build on it.
Comparison: FEAT-006 (Docker) vs. FEAT-007 (Native)¶
| Aspect | FEAT-006 (Docker) | FEAT-007 (Native) |
|---|---|---|
| Target | Dev/test, full Windows Server | S7-1500 Open Controller, Windows IoT |
| Process model | 3 containers (separate processes) | 1 process (unified host) |
| Cross-API events | Redis Pub/Sub required | In-memory Subject |
| Runtime dependency | Docker Engine + Docker Compose | None (self-contained .exe) |
| RAM overhead | ~420 MB (3x .NET runtime) + Redis | ~200 MB (1x .NET runtime) |
| Deployment | docker compose up |
install.ps1 (Windows Service) |
| Air-gapped | docker save/load (large images) |
USB copy (single .exe + config) |
| Complexity | Higher (Docker, Redis, networking) | Lower (single binary, sc.exe) |
Risks and Considerations¶
-
Single-process failure domain -- If the unified host crashes, all APIs go down simultaneously. Mitigation: Windows Service auto-restart (
sc.exe failurerecovery options); health check monitoring. This is acceptable for the S7-1500 use case where all APIs serve the same microfactory. -
Port conflicts on Kestrel -- REST and GraphQL need separate ports or path-based routing. Mitigation: evaluate during Phase 2; two-port Kestrel config is well-supported in ASP.NET Core.
-
OPC UA library compatibility -- The OPC UA library runs as a separate server on port 4840 (TCP, not HTTP). Hosting it as a
BackgroundServicein the same process is standard but needs verification with the OPC Foundation stack. -
Intel Atom performance -- The E3940 is a low-power processor. Mitigation: benchmark on target hardware during Phase 5; reduce connection pools (Phase 4); disable unused APIs via config toggles.
-
Windows IoT Enterprise limitations -- Some Windows Server features (e.g., advanced service management, IIS) may not be available. Mitigation: use only Kestrel (no IIS dependency) and
sc.exefor service management, both available on Windows IoT Enterprise. -
Self-contained publish size -- Single-file self-contained builds are ~60-80 MB. Mitigation: acceptable for USB deployment; compression reduces transfer size.
-
MariaDB co-location resource contention -- MariaDB and Essert.MF compete for the same 8 GB RAM and 4 CPU cores. Mitigation: tune connection pool sizes and MariaDB buffer pool (Phase 4); monitor with performance benchmarks.
Verification Plan¶
dotnet build-- solution compiles with new Essert.MF.Host projectdotnet test Essert.MF.Domain.Tests-- domain tests passdotnet test Essert.MF.Application.Tests-- application tests passdotnet publishself-contained -- produces single-file executable- Install as Windows Service --
sc.exe createsucceeds, service starts curl http://localhost:5000/health-- REST API returns healthycurl http://localhost:5010/graphql-- GraphQL endpoint responds- OPC UA client connects to
opc.tcp://localhost:4840-- browsing works - Cross-API event test -- POST via REST, verify GraphQL subscription receives event (in-process)
- Memory usage -- total process RSS < 300 MB under normal load
install.ps1on clean Windows 10/11 -- installs and starts without .NET runtime pre-installed