Beta

OPC UA to the cloud, without a SCADA layer

Tag Historian ingests OPC UA through a collector you run next to your PLC, not by reaching into your control network from the cloud. One container, one mounted volume, one explicit list of NodeIds.

This connector is marked Beta in the product, so it is marked Beta here. It works and it is documented; it has fewer field hours behind it than the MQTT and line-protocol paths.

Last updated

Why a collector, and why it never listens

An OPC UA server on a plant LAN sits behind NAT and — if the network is set up properly — behind a firewall that admits nothing inbound. Any product that proposes to connect into your control network is asking you to undo that.

The collector connects outward instead: outward to the PLC's endpoint on the control network, outward over HTTPS to the API. No port forwarding, no inbound firewall rule, no PLC exposed to the internet.

1. Get an API key

Create an account and an API key first. The free plan is €0 and takes an email address — no card — and gives you 5 tags and 50,000 readings a day. Then create the key on the Settings page in Tag Historian Explorer — and the scope you give it is not a detail.

The key is shown once. Copy it straight into the container’s environment — if you lose it, generate a new one rather than hunting for the old one.

2. Write a config file

The collector reads its configuration and keeps its offline buffer in one directory, /config. This one subscribes to two tags on a PLC:

/config/appsettings.json
{
  "TagHistorian": {
    "ApiUrl": "https://api.taghistorian.com",
    "ApiKey": ""
  },
  "MqttSources": [],
  "OpcServers": [
    {
      "Name": "plant",
      "EndpointUrl": "opc.tcp://192.168.1.20:4840",
      "UseSecurity": false,
      "Tags": [
        { "NodeId": "ns=2;s=Boiler.Temperature", "TagName": "boiler.temperature", "SamplingIntervalMs": 1000 },
        { "NodeId": "ns=2;s=Boiler.Pressure",    "TagName": "boiler.pressure",    "SamplingIntervalMs": 1000 }
      ]
    }
  ]
}

UseSecurity: false is the right first move: prove the plumbing on an unsecured endpoint, then turn security on once data is flowing.

3. Run it

docker run
docker run -d --name tag-collector \
  -v mydata:/config \
  -e TagHistorian__ApiKey="<your-api-key>" \
  --restart unless-stopped \
  ghcr.io/softi-dev/tag-historian-clients/collector:latest

The same volume holds the store-and-forward queue (/config/queue) and the collector's certificate store (/config/pki), so buffered readings and the trust you have established both survive a container recreate. It is the same image that speaks MQTT — one collector, both protocols.

Subscriptions, not polling

The collector does not poll. It creates an OPC UA subscription and asks the server to do the sampling: SamplingIntervalMs is how often the server samples the node, and changed values are pushed to the collector in batches on the publishing interval (SubscriptionPublishingIntervalMs, default 1000 ms).

  • An unchanged value costs nothing on the wire. A tag sampled every 100 ms that only moves once a minute sends one notification a minute.
  • Values that change faster than the publishing interval are not lost — the server queues them per item (MonitoredItemQueueSize, default 10) and delivers the queue on the next publish.
  • Timestamps are the server's source timestamps, not the collector's arrival times, so a reading is stamped when the value changed rather than when it happened to be delivered.

NodeIds become tags

There is no browsing and no wildcard. Every tag is one explicit line in Tags, mapping one NodeId to one tag name.

NodeIdTagName
ns=2;s=Boiler.Temperatureboiler.temperature
ns=2;s=Line1.Motor.RPMline1.motor.rpm
ns=3;i=1204furnace.o2

That is more typing than a browse-and-select tree, and it is on purpose: one row is exactly one tag against your plan's quota, so an OPC UA source can never surprise you with a tag explosion the way a # wildcard on a busy MQTT broker can. What you listed is what you pay for.

Values and quality

Integers, floats and booleans are stored — booleans as 1 and 0, so an hour in which a pump ran fifteen minutes averages to 0.25, its duty cycle. A string-valued node stores nothing at all: it is dropped and reported in the log, never turned into an invented zero.

Every reading carries the exact OPC UA StatusCode the server reported — Good, UncertainLastUsableValue, BadSensorFailure, the rest — stored alongside the value as a full 32-bit code rather than a good/bad flag. A sensor reporting garbage with a Bad quality is distinguishable from a sensor reporting garbage confidently, which is the whole point of quality codes.

When a sensor drops out, the Bad or valueless sample is neither discarded nor interpolated across. It is stored as an outage marker — the exact StatusCode, carried alongside the tag's last known good value — so the interruption is visible in the history instead of silently bridged. The one exception is startup: until a tag has produced its first Good sample there is no last-known-good value to carry, so no marker and no fabricated 0.0 is stored. The first value written for any tag is always a real reading.

Security

one server in OpcServers
{
  "Name": "plant",
  "EndpointUrl": "opc.tcp://192.168.1.20:4840",
  "SecurityPolicy": "Basic256Sha256",
  "SecurityMode": "SignAndEncrypt",
  "PkiPath": "pki"
}
  • SecurityPolicyNone or Basic256Sha256. When set, the collector requires an endpoint with exactly this policy.
  • SecurityModeNone, Sign or SignAndEncrypt. Same rule: set means exact match.
  • If the server offers no endpoint matching what you asked for, the collector fails fast and prints the server's actual endpoint list — policy, mode and URL for each — so the fix is a config edit, not a packet capture. It never silently downgrades to a weaker endpoint than the one you named.

UseSecurity is kept for backwards compatibility: true with no policy or mode means “pick the best secured endpoint the server offers”. It demands a strong policy and a Sign/SignAndEncrypt mode, and if the server's unauthenticated endpoint list contains no such endpoint — the shape of a man-in-the-middle that stripped the secure ones — the collector fails loudly instead of quietly connecting in plaintext. Setting the policy and mode explicitly is the precise form and the one to prefer in new configs.

By default the collector does not check that the endpoint hostname appears in the server's certificate (ValidateEndpointDomain: false). PLCs are almost always reached by IP while their certificate names an internal hostname, so a domain check would reject a server whose certificate you have explicitly trusted. Set it true where the subject and the address genuinely line up.

Trust goes both ways

OPC UA security is mutual: the collector must trust the server's certificate and the server must trust the collector's. Both halves are files.

  1. On first start the collector generates a self-signed certificate under pki/own/certs/. Copy it to the OPC UA server and mark it trusted — on most PLCs and SCADA servers that is a “trusted clients” list; some instead quarantine the first connection attempt as rejected and let you promote it.
  2. Export the server's certificate and drop it in pki/trusted/certs/ on the collector side.

Until both are done, a secured connect fails with a certificate error naming the untrusted side. That is OPC UA working as designed, not the collector being difficult: the alternative — auto-trusting whatever answers — would make the encryption decorative.

What happens when the uplink drops

Settings

Per server in OpcServers:

SettingDefaultNotes
NameRequired. Appears in every log line about this server.
EndpointUrlRequired. opc.tcp://host:port
UseSecurityfalsetrue without policy/mode = strongest signed endpoint; fails rather than downgrade to plaintext.
SecurityPolicyNone or Basic256Sha256. Set means exact match, or fail with the endpoint list.
SecurityModeNone, Sign or SignAndEncrypt. Same exact-match rule.
ValidateEndpointDomainfalseWhether the endpoint hostname must appear in the server certificate.
PkiPathpkiCertificate store root — /config/pki in the container.
ApplicationUriClient certificate URN. Unset = a stable id persisted under PkiPath.
Username / PasswordAnonymous when unset.
SubscriptionPublishingIntervalMs1000How often the server pushes accumulated changes.
MonitoredItemQueueSize10Server-side queue per item for fast-changing values.
SessionTimeoutSeconds60
ReconnectIntervalSeconds10The collector reconnects forever, by design.
TagsRequired. One row per tag.

Per tag in Tags:

SettingDefaultNotes
NodeIdRequired. e.g. ns=2;s=Boiler.Temperature or ns=3;i=1204
TagNameRequired. The tag name in Tag Historian.
SamplingIntervalMs1000How often the server samples the node — not a collector poll rate.
DescriptionStored as tag metadata.
UnitsStored as tag metadata.
CompressionDeadbandOne number, two meanings — see “Picking CompressionDeadband” below. At the server it becomes an OPC UA DataChangeFilter with DeadbandType.Absolute, a distance in the tag’s own engineering units, so in-deadband samples never cross the network. At the historian the same number is a fraction of the last stored value. StatusValue triggering, so a pure quality change still gets through.
EnabledtrueKeep the row, stop collecting.

Picking CompressionDeadband

One configured number reaches two filters that do not mean the same thing. At the PLC it is an OPC UA DataChangeFilter with DeadbandType.Absolute — a distance in engineering units. At the historian it is a fraction of the last stored value. On a tag reading around 50, a deadband of 0.5 means “suppress moves under 0.5 units” at the PLC and “store only on moves over 50%” at the historian.

The historian's fraction is almost always the binding constraint, and it decides what you can query back later, so size the number as a fraction first: 0.0001 stores on a move over 0.01%, 0.001 over 0.1%, 0.01 over 1%. Then sanity-check the PLC side. On a tag reading around 50, 0.001suppresses moves under 0.001 engineering units — effectively nothing — so the PLC filter stays permissive and the historian does the real work. That inverts on small-valued tags: on one reading around 0.002, a deadband of 0.001 is a 50% filter at the source and readings are suppressed before they ever reach you.

The valid range for the storage-side fraction is 0 to 1. Values above 1 are accepted without an error today and mean “store only on a change larger than 100% of the last stored value”, which suppresses nearly every sample — so a number chosen as if it were engineering units (2 for “2 °C”) fails silently rather than loudly.

Trying it without a PLC

The repository ships a simulator — a real OPC UA server built on the OPC Foundation stack, with about 95 tags across three simulated plants, offering both an unsecured endpoint and Basic256Sha256 in Sign and SignAndEncrypt. Point the collector at opc.tcp://opc-simulator:4840 and tags flow without any hardware.

What this is not

  • We do not write to your PLC. Ingestion only; the collector never writes a value to any node. The subscription is read-only by construction.
  • We do not browse — yet. There is no address-space discovery; every NodeId is written by hand. Browsing is on the roadmap; the quota-explosion problem it brings is why it is not here already.
  • This is not a SCADA system. No HMI, no control, no alarming on the plant floor. It stores history and alerts on it. If you need supervisory control, you need a SCADA platform, and what those cost is written up here.

Common questions

Do I need SCADA software to log OPC UA data to the cloud?
No. A collector container subscribes directly to the OPC UA server and forwards readings over HTTPS. There is no SCADA layer, no historian server on the plant network, and no inbound firewall rule — the collector connects outward in both directions.
Does the collector write to my PLC?
No. The subscription is read-only by construction — the collector never writes a value to any node.
Can it browse the address space?
Not yet. Every NodeId is written by hand in the Tags list. That is more typing than a browse-and-select tree, and it means one row is exactly one tag against your quota, so an OPC UA source can never surprise you with a tag explosion.
Are OPC UA quality codes preserved?
Yes. The full 32-bit StatusCode the server reported is stored alongside every value, not a one-byte good/bad flag. A Bad or valueless sample is stored as an outage marker carrying the tag's last known good value, so an interruption is visible in the history rather than silently bridged.

Try it on the free plan

5 tags, 50,000 readings a day, 14 days at full resolution and ten years of hourly summaries. €0, no card.