Skip to content

Extending with Nuplane

Sipke Schoorstra edited this page Aug 11, 2026 · 5 revisions

Extending with Nuplane

Adding database providers, message buses and schedulers without rebuilding the image.


What Nuplane is

The base image ships deliberately minimal. Nuplane is a runtime plugin loader: it resolves NuGet packages from configured feeds at startup and loads them into the host. Capabilities become configuration rather than a new image build.

This is why you can run one published image and still use PostgreSQL, RabbitMQ and Quartz — none of which are baked in.

Two halves:

Section Does
Nuplane:Setup Where packages come from — feeds and which packages to take from each
Nuplane:Loading How they're loaded — load mode, shared assemblies

Loading a package makes a feature available. Enabling it is separate, under CShells:Shells:<name>:Features. Both steps are required — a loaded-but-unconfigured package does nothing.


Feeds

Remote NuGet feed

{
  "Nuplane": {
    "Setup": {
      "Feeds": [
        {
          "Name": "elsa-feed",
          "ServiceIndex": "https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json",
          "IncludePatterns": [
            "Elsa.Persistence.EFCore.PostgreSql [3.8.0-preview,)",
            "Elsa.Scheduling.Quartz.EFCore.PostgreSql [3.8.0-preview,)",
            "Elsa.ServiceBus.MassTransit.RabbitMq [3.8.0-preview,)"
          ]
        }
      ]
    }
  }
}

IncludePatterns entries are a package ID plus an optional NuGet version range. [3.8.0-preview,) means "3.8.0-preview or newer".

⚠️ Open-ended ranges resolve at startup. A restart can pick up a newer package than the one you tested. For production, pin exact versions — [3.8.0-preview.4623] — so that a container restart is not an unplanned upgrade.

Local directory

Private packages you don't want on a feed — and the route to air-gapped and offline startup.

{
  "Name": "local-packages",
  "DirectoryPath": "packages",
  "IncludePatterns": ["*"],
  "Directory": { "Watch": true, "DebounceWindow": "00:00:01" }
}

Mount .nupkg files in:

docker run ... -v "$(pwd)/packages:/app/packages" ghcr.io/valence-works/runtime-ce-server:latest

The mount may be :ro, and needs no particular ownership.

Watch: true picks up packages dropped in at runtime. Convenient in development; in production it means the filesystem can change what code runs, so disable watching there.

Offline and air-gapped startup

By default a container resolves its packages from the remote feeds on every start, so a feed outage stops it booting. Pre-resolving your packages into a mounted directory is the intended way to remove that dependency.

Populate a directory with the full transitive closure of the packages you use — not just the ones you named — and mount it:

docker run --network none -v "$(pwd)/packages:/app/packages:ro" ... \
  ghcr.io/valence-works/runtime-ce-server:latest

The one thing that catches people out is the word closure: a directory holding only the packages listed in your IncludePatterns is not enough, because their dependencies have to resolve from somewhere too. The simplest way to produce it is to start the container once with network access and copy what it resolved.

Verified with --network none and a read-only package mount: the full set loads and the host serves normally.

Why a directory rather than packages baked into the image: what you need depends on the database, message bus and scheduler you chose, and that is a deployment decision, not a build-time one. Pre-resolving your own set covers PostgreSQL, RabbitMQ or anything else you add; a fixed set chosen by us would only ever cover the defaults.

Reconciliation

{
  "Nuplane": {
    "Setup": {
      "AutomaticReconciliation": false
    }
  }
}

When enabled, Nuplane re-checks feeds on PollInterval and reconciles. The images ship with this off, and you should leave it off in production — package changes then happen on your deploy schedule rather than on a timer.

Reconciliation:EnableAutomaticReconciliation is the equivalent longhand and takes precedence over the Setup shorthand.

Two defects here are worth knowing about if you are upgrading from an earlier image: an explicit EnableAutomaticReconciliation: false was silently discarded, and every reconciliation cycle after startup left reconciliation permanently degraded — silently, with /health and /alive still returning 200. Both fixed as of these images.


Common additions

PostgreSQL persistence

"IncludePatterns": ["Elsa.Persistence.EFCore.PostgreSql [3.8.0-preview,)"]
"PostgreSqlWorkflowPersistence": { "ConnectionString": "Host=postgres;…" },
"PostgreSqlIdentityPersistence": { "ConnectionString": "Host=postgres;…" }

→ Persistence and Databases

RabbitMQ

"IncludePatterns": ["Elsa.ServiceBus.MassTransit.RabbitMq [3.8.0-preview,)"]
"MassTransitRabbitMq": { "ConnectionString": "amqp://guest:guest@rabbitmq:5672" }

Needed for distributed execution across replicas.

Quartz scheduling

"IncludePatterns": ["Elsa.Scheduling.Quartz.EFCore.PostgreSql [3.8.0-preview,)"]
"QuartzPostgreSql": { "ConnectionString": "Host=postgres;…" }

Durable, database-backed scheduling that survives restarts — as opposed to in-memory scheduling, which does not. If you use timer or cron triggers in production, you want this.


Load modes

{
  "Nuplane": {
    "Loading": {
      "Enabled": true,
      "DefaultLoadMode": "HostIntegrated",
      "SharedAssemblies": [{ "Name": "CShells.Abstractions" }]
    }
  }
}

HostIntegrated loads packages into the host's context — types are shared directly with the host. This is the default and the right choice for Elsa feature packages, which must integrate with host services.

SharedAssemblies lists assemblies that must resolve to the host's copy rather than a plugin-loaded duplicate. Getting this wrong produces the classic confusing failure: a type appearing not to implement an interface it visibly implements, because two copies of the interface are loaded from different contexts.

Unless you're building custom plugins, leave this section as shipped.


Verifying it worked

docker logs elsa-pro 2>&1 | grep -i nuplane

Look for resolution and load lines for each expected package. Then confirm the feature actually activated — a package can load while its feature stays disabled because configuration is missing or misspelled.

When a feature doesn't activate, in order:

  1. Is the package listed in an IncludePatterns on a reachable feed?
  2. Can the container reach the feed? (Air-gapped or egress-filtered networks silently fail here.)
  3. Does the version range match a package that actually exists?
  4. Is the feature name spelled exactly right under CShells:Shells:<name>:Features?
  5. Is it under the shell you're actually running — Default, not 0?

→ Troubleshooting


Custom packages

Nuplane loads any compatible NuGet package, including your own activities and extensions. Build a package, drop it in the local directory feed or publish it to a private feed you configure.

Note the support boundary: self-built or third-party packages are outside the scope of Valence Runtime support, since the running system is then not a build Valence Works produced. Reproduce an issue without your custom packages before reporting it. → Editions and Support


Next: Production Checklist · Troubleshooting · Configuration

Clone this wiki locally