Skip to main content

Configuration

Distributed mode has two layers of configuration: the core DistributedOptions (shared across all coordinator implementations) and backend-specific options like RedisOptions.

Module identity and build compatibility​

Assignments, stored results, dependency references, artifact descriptors, and worker command counts use ModuleId. The value is case-sensitive and serializes as a JSON string. By default it derives from the module's full type name; generic arguments omit assembly versions. Assign an explicit ID to preserve identity when renaming or moving a module:

using ModularPipelines.Attributes;

[ModuleId("build.application")]
public class BuildModule : Module<string>
{
protected override Task<string> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
=> Task.FromResult("output");
}

Each registered module must have a unique ID. Cache fingerprints also use this identity; existing assembly-version cache settings still control cache invalidation. This changes the fingerprint format, so existing caches are refreshed once.

Workers publish PipelineSchemaVersion during registration, and masters include it in every assignment. The stamp includes the wire schema, registered module IDs, result type identities, and application assembly build IDs throughout module inheritance and result contracts, including generic arguments, element types, implemented collection interfaces, base types, serialized property and field types, attributed JSON converters, and polymorphic contracts declared with JsonDerivedType. Run the same pipeline binaries on every participant. Missing or mismatched stamps produce a schema mismatch error before module execution; they do not silently execute against a different build. A stable module ID does not bypass this build compatibility check.

Shared .NET framework assemblies contribute stable type names instead of assembly build IDs, so shared-runtime servicing updates do not invalidate the stamp. Application types inside framework containers such as List<MyResult> still require matching builds, as do application-provided replacements for framework assemblies. Self-contained deployments and custom hosts without shared-framework metadata should deploy matching runtime binaries as well.

The wire DTO changes are breaking: upgrade masters, workers, and custom coordinators together. WaitForResultAsync accepts ModuleId, and SerializedModuleResult resolves its result type through the local registry rather than a remote result type name.

DistributedOptions​

Passed to AddDistributedMode(). Controls the fundamental behavior of the master/worker system.

All distributed and backend options are immutable records. Return updated options from configuration callbacks using options => options with { ... }. Update nested tunnel options with options.Tunnel with { ... }. Transforms and configuration-section bindings apply in registration order; startup validation runs after configuration and post-configuration. Named module-cache options remain separate from distributed backend options.

Use builder.ConfigureDistributedOptions(options => options with { ArtifactCompressionLevel = CompressionLevel.NoCompression }) to configure artifact behavior in local mode. This does not enable distributed execution. Redis ConfigureConnection options are Func<RespireOptions, RespireOptions> transforms over the immutable Respire connection options.

builder.AddDistributedMode(o => o with
{
InstanceIndex = 0,
TotalInstances = 4,
MaxParallelism = 4,
Role = DistributedRole.Master,
Capabilities = [Capability.Docker, Capability.Gpu],
RunId = Environment.GetEnvironmentVariable("MODULARPIPELINES_RUN_ID")!,
WorkerRegistrationTimeout = TimeSpan.FromMinutes(5),
WorkerHeartbeatInterval = TimeSpan.FromSeconds(5),
WorkerTimeout = TimeSpan.FromSeconds(30),
MasterTimeout = TimeSpan.FromMinutes(1),
MinimumWorkerCount = 0,
ModuleResultTimeout = TimeSpan.FromMinutes(45),
});
PropertyTypeDefaultDescription
RoleDistributedRoleAutoExplicit Master or Worker role. Auto derives the role from InstanceIndex.
InstanceIndexint0This instance's unique index, less than TotalInstances. With Role == Auto, 0 selects master and values above 0 select worker. Each process's WorkerId is instance-{InstanceIndex}.
TotalInstancesint1Total number of instances (master + workers). Values above 1 require a shared coordinator backend.
MaxParallelismint?nullOptional per-node concurrency limit. It can lower, but cannot raise, the pipeline's global Concurrency.MaxParallelism limit. Workers advertise it when they register.
CapabilitiesIReadOnlyList<Capability>[]Capabilities this instance advertises in addition to those detected by registered ICapabilityProvider services, including the current OS. Built-in values are available from Capability; create custom values with new Capability("name").
RunIdstringMODULARPIPELINES_RUN_ID or generated for one instanceIdentifier shared by every process in this pipeline run: 1-128 ASCII letters, digits, ., _ or -, because backends embed it in keys and URLs. Multi-instance runs fail fast when neither source is configured.
RequireExplicitRunIdboolfalseReject generated single-instance IDs. The Redis and SignalR coordinators and Redis discovery enable this automatically. S3 artifact-store registrations also require an explicit shared RunId, including for single-instance configurations. Custom backends opt in with builder.RequireExplicitRunId().
WorkerRegistrationTimeoutTimeSpanTimeSpan.FromMinutes(5)How long the master waits for workers to register: for MinimumWorkerCount before dispatch starts, and for a capable worker before an assignment the master cannot run fails with a routing error.
WorkerHeartbeatIntervalTimeSpanTimeSpan.FromSeconds(5)How often workers, and the master's own worker loop, report liveness and renew the leases on their in-flight modules.
WorkerTimeoutTimeSpanTimeSpan.FromSeconds(30)How long a registration and its leases stay valid without a heartbeat. When a lease expires the master returns its module to the queue. Must exceed WorkerHeartbeatInterval.
MasterTimeoutTimeSpanTimeSpan.FromMinutes(1)How long workers keep running after the master stops sending heartbeats without having signalled completion, or while they cannot reach the coordinator to check. Workers then cancel their in-flight modules, including AlwaysRun modules, and fail. SignalR workers detect a lost master when their connection closes for good instead. Must exceed WorkerHeartbeatInterval.
MinimumWorkerCountint0Number of external workers required before dispatch starts, between zero and TotalInstances - 1. Keep zero for immediate dispatch.
ModuleResultTimeoutTimeSpanTimeSpan.FromMinutes(45)The master's backstop for a claimed module. The deadline starts when a worker claims the module and allows every configured attempt at the module's timeout plus this period; the worker enforces the per-attempt timeout itself. Use Timeout.InfiniteTimeSpan to wait indefinitely; zero and other negative values are rejected.
RunCleanupDistributedRunCleanupPolicyAlwaysWhich pipeline outcomes delete the data backends stored for the run. See Run data cleanup.
RunCleanupTimeoutTimeSpanTimeSpan.FromMinutes(2)How long cleanup waits for workers to publish their final status, and how long each backend may take to delete the run's data. Cleanup is skipped when a worker does not finish in time.

Invalid combinations, such as InstanceIndex >= TotalInstances, WorkerTimeout <= WorkerHeartbeatInterval or MasterTimeout <= WorkerHeartbeatInterval, are reported by pipeline validation and stop the distributed backend from starting.

Configuration from appsettings.json​

You can also bind from configuration:

{
"Distributed": {
"InstanceIndex": 0,
"TotalInstances": 4,
"RunId": "unique-invocation-id",
"Capabilities": ["docker"],
"WorkerRegistrationTimeout": "00:05:00",
"MinimumWorkerCount": 0
}
}
builder.AddDistributedMode(builder.Configuration.GetSection("Distributed"));

Or call builder.AddDistributedMode() to bind the standard environment variables:

Environment variableOption
MODULARPIPELINES_INSTANCE_INDEXInstanceIndex
MODULARPIPELINES_TOTAL_INSTANCESTotalInstances
MODULARPIPELINES_MAX_PARALLELISMMaxParallelism
MODULARPIPELINES_RUN_IDRunId
MODULARPIPELINES_ROLERole (Auto, Master, or Worker)

Configuration binding converts the string array to Capability values. Distributed wire payloads also remain plain JSON strings.

Every satellite package registers through one Func<TOptions, TOptions> overload and one IConfigurationSection overload, validates its options at startup, and can be called before or after AddDistributedMode:

builder.AddRedisDistributedCoordinator(builder.Configuration.GetSection("Redis"));
builder.AddSignalRDistributedCoordinator(builder.Configuration.GetSection("SignalR"));
builder.AddRedisMasterDiscovery(builder.Configuration.GetSection("RedisDiscovery"));
builder.AddS3DistributedArtifactStore(builder.Configuration.GetSection("S3"));

AddRedisMasterDiscovery keys the advertised master endpoint by DistributedOptions.RunId and enables RequireExplicitRunId, so every process in the run must share one explicit RunId: set it in the Distributed section, return options with { RunId = "shared-run-id" } from the configuration callback, or export MODULARPIPELINES_RUN_ID for each master and worker. See Run Identifier Resolution.

Each backend registration has exactly two overloads: one taking a Func<TOptions, TOptions> and one taking an IConfigurationSection. Only one coordinator backend and one artifact store backend can be registered; registering a second, different backend throws InvalidOperationException instead of silently replacing the first. Registering the same backend again is a no-op apart from applying the extra configuration.

The same conflict rule applies to AddDistributedArtifactStore<TStore>() and AddDistributedArtifactStoreFactory<TFactory>(): do not mix a direct store and a factory, regardless of registration order. Repeating the same typed registration is a no-op. Keyed services are independent, and pipelines without an explicit store retain the default filesystem store. In V4, direct store registration no longer silently removes an earlier factory; choose one backend at the registration call site instead.

Artifact compression​

DistributedOptions.ArtifactCompressionLevel applies to every artifact store, including in local mode. Configure it with the options pattern, independently of the backend:

builder.ConfigureDistributedOptions(o => o with { ArtifactCompressionLevel = CompressionLevel.Optimal });
// Or return updated options from your existing AddDistributedMode configuration:
builder.AddDistributedMode(o => o with { ArtifactCompressionLevel = CompressionLevel.Optimal });
PropertyTypeDefaultDescription
ArtifactCompressionLevelCompressionLevelFastestCompression level for directory and glob artifacts.

Storage-specific settings such as expiry, chunk size and multipart part size live on the backend's options (RedisOptions, S3StorageOptions).

Run data cleanup​

When a distributed pipeline ends, backends that implement IDistributedRunCleanup delete the data they stored for the run. Only the master cleans up. Workers never do, and neither do pipelines that do not call AddDistributedMode, because separate local runs can share a run identifier and its artifacts. Cleanup starts after the pipeline end handlers and the run report have finished, and only once every worker registered for the run has published its final status. Workers publish it after their own pipeline end handlers, so none still uses the run's data. If a worker does not publish one within RunCleanupTimeout, for example because it crashed, cleanup is skipped with a warning and each backend's own expiry removes the data.

DistributedOptions.RunCleanup selects when cleanup runs:

ValueBehavior
Always (default)Deletes the run's data whatever the outcome.
OnSuccessDeletes the run's data when the pipeline succeeded. A failed or canceled run keeps its data for investigation until each backend's own expiry removes it.
NeverKeeps the run's data; only each backend's own expiry removes it.
builder.AddDistributedMode(o => o with
{
RunCleanup = DistributedRunCleanupPolicy.OnSuccess,
RunCleanupTimeout = TimeSpan.FromMinutes(5),
});

Cleanup is best effort. A backend that fails, or does not finish within RunCleanupTimeout, is logged as a warning and never changes the pipeline result. Backend expiry, such as RedisOptions.TimeToLive or the S3 lifecycle rule, still removes data from runs that crashed or kept their data. Module caches are shared across runs, so cleanup never deletes them.

The built-in backends clean up as follows:

BackendDeletes
Redis coordinatorEvery run coordination key except the completion flag, which expires with TimeToLive so a worker that starts late sees a completed master and exits.
Redis artifact storeEvery artifact uploaded in the run, by any process, and the indexes that list them.
S3 artifact storeEvery object under {KeyPrefix}/artifacts/{RunId}/. In a versioned bucket it deletes every version and delete marker, so no artifact stays recoverable. Providers without a versioning API, such as Cloudflare R2, fall back to deleting the current objects.
Redis master discoveryThe endpoint and access token this master advertised.

To make a custom coordinator, artifact store or discovery service clean up, implement IDistributedRunCleanup on it. Any other service can take part by registering itself as IDistributedRunCleanup. Each backend is called once, even when it is registered more than once. See the architecture guide for the contract.

RedisOptions​

The Redis coordinator requires Redis 7.4 or later, or Valkey 9.0 or later. Worker heartbeats use hash field expiry (HPEXPIRE), and notifications use sharded pub/sub (SPUBLISH), which keeps a run's messages on the shard that owns its keys in Redis Cluster. An older server fails when the coordinator is created.

Passed to AddRedisDistributedCoordinator(), AddRedisDistributedArtifactStore(), AddRedisDistributed() and AddRedisModuleCache(). Controls how the Redis features connect and manage keys. The coordinator and artifact store share one RedisOptions instance but open separate connections, so large artifact transfers do not queue ahead of coordinator heartbeats on the client. Both connections still share the network link and the Redis server, so size them for the combined load. The module cache has its own options and connection, so it can use a different Redis server.

builder.AddRedisDistributed(o => o with
{
ConnectionString = "redis-host:6379",
ConfigureConnection = connection => connection with
{
Password = Environment.GetEnvironmentVariable("REDIS_PASSWORD"),
UseTls = true,
},
KeyPrefix = "modpipe",
TimeToLive = TimeSpan.FromHours(1),
});
PropertyTypeDefaultDescription
ConnectionStringstring""Respire connection string: host:port, a redis:// or rediss:// URI, or a StackExchange.Redis-style comma-delimited string (password, ssl, asyncTimeout, etc.). Unknown options such as abortConnect fail validation. Required unless ConfigureConnection supplies the endpoints.
ConfigureConnectionFunc<RespireOptions, RespireOptions>?nullTransforms the parsed RespireOptions before connecting, usually with a with expression. Use it for passwords containing commas or other characters a connection string cannot carry, TLS, timeouts, and retry settings.
KeyPrefixstring"modpipe"Prefix for all Redis keys. Change this if multiple different pipelines share the same Redis instance.
TimeToLiveTimeSpanTimeSpan.FromHours(1)TTL for all Redis keys, including artifacts and module cache entries. Keys are automatically cleaned up after this duration, and run data cleanup deletes a finished run's keys earlier.
ChunkSizeBytesint1 MBSize of each Redis value used for artifacts and module cache entries. Content that fits in one chunk is stored under a single key. Each chunk must transfer within the connection's CommandTimeout, so lower it or raise the timeout for slow or shared links.

Each registration owns its connection and connects asynchronously on first use. The package never registers or resolves an IRespireClient from the service collection, so an application's own client is not adopted and cannot conflict with it.

Unless the connection string or ConfigureConnection sets one, the connection uses Respire's default RespireRetryPolicy. After a dropped connection, Respire fails the next command while it reconnects; the policy resends commands that were never written, and reads, so a brief disconnect does not fail coordination. Set RetryPolicy = null in ConfigureConnection to turn it off.

The options are validated when the pipeline is built. After configuration and post-configuration finish, ConfigureConnection runs once per materialized options instance. The effective configuration must have at least one endpoint: a no-op or TLS-only callback without a connection string fails, as does a callback that removes every parsed endpoint. A connection string that Respire cannot parse also fails. Validation does not connect to Redis. Connections and retries reuse copies of the validated snapshot; later option changes or changes to an endpoint list the callback kept do not change that snapshot. Named module-cache options have their own snapshots.

A missing connection, an empty KeyPrefix, or a non-positive TimeToLive or ChunkSizeBytes fails fast with OptionsValidationException. For the coordinator and artifact store, TimeToLive must also exceed DistributedOptions.ModuleResultTimeout so run keys and artifacts cannot expire mid-run, and DistributedOptions.MasterTimeout so workers see a stopped master's heartbeat go stale before it expires. An infinite module result timeout does not disable Redis key expiration: keys retain their configured TTL, so choose a TTL that covers the required retention period.

All distributed duration properties use TimeSpan. When binding them from appsettings.json, use the invariant TimeSpan string format:

{
"Distributed": {
"WorkerRegistrationTimeout": "00:05:00",
"WorkerHeartbeatInterval": "00:00:05",
"WorkerTimeout": "00:00:30",
"MasterTimeout": "00:01:00",
"ModuleResultTimeout": "00:45:00"
},
"Redis": {
"TimeToLive": "01:00:00"
},
"SignalR": {
"ConnectionTimeout": "00:02:00",
"KeepAliveInterval": "00:00:05",
"PeerTimeout": "00:00:15",
"Tunnel": {
"StartupTimeout": "00:00:30"
}
},
"RedisDiscovery": {
"TimeToLive": "01:00:00",
"DiscoveryTimeout": "00:02:00",
"PollInterval": "00:00:00.500"
}
}

Run Identifier Resolution​

Distributed coordination requires an invocation-scoped identifier. It is resolved in this order:

PrioritySourceEnvironment
1DistributedOptions.RunIdExplicit configuration
2MODULARPIPELINES_RUN_ID env varAny CI or local orchestration
3Generated GUIDSingle-process/default fallback

Use an invocation-specific value rather than a stable commit identifier so rerunning the same commit receives a fresh Redis namespace. Local multi-process runs should export one unique MODULARPIPELINES_RUN_ID value before starting the master and workers. CI workflows must likewise generate or derive one invocation-specific value and export it as MODULARPIPELINES_RUN_ID for every master and worker.

Redis Key Schema​

All coordination keys follow the pattern {KeyPrefix}:{{RunId}}:{purpose}. The braces around the run identifier form a Redis Cluster hash tag, so every key of one run lives in one slot and the Lua scripts can touch several keys atomically. With the defaults, keys look like:

KeyRedis TypePurpose
modpipe:{run}:work:queueSorted setQueued assignments, scored by priority and critical-path weight
modpipe:{run}:work:leasesHashActive leases (field = module ID; the value holds the lease ID, worker ID and expiry)
modpipe:{run}:resultsHashFinal module results (field = module ID), written with HSETNX so the first result wins
modpipe:{run}:workersHashWorker registrations (field = worker ID) and heartbeat:{worker ID} timestamps
modpipe:{run}:workers:statusHashLatest WorkerStatus per worker
modpipe:{run}:completionStringSet when the master signals completion
modpipe:{run}:cancellationStringCancellation reason (PipelineFailed or Stopped)

Pub/Sub channels (no TTL, ephemeral):

ChannelPurpose
modpipe:{run}:work:availableWakes workers waiting to claim work
modpipe:{run}:results:{ModuleId}Wakes waiters for a module's result
modpipe:{run}:completion:signalWakes workers when the master signals completion
modpipe:{run}:cancellation:signalWakes cancellation observers

Notifications only shorten waits: every wait also re-reads Redis every two seconds, so a message lost while the connection reconnects cannot strand a waiter. Heartbeats and the master's lease sweep refresh the expiry of every coordination key, and RedisOptions.TimeToLive must exceed WorkerTimeout, ModuleResultTimeout and MasterTimeout, so keys cannot expire while a run is active. Only the master renews its own heartbeat, while a marker that the master started is renewed with the other run keys, so a master whose heartbeat went stale or expired is reported as lost rather than as not started yet.

Artifacts are stored under the same run namespace:

KeyRedis TypePurpose
modpipe:{run}:artifacts:meta:{id}StringArtifact metadata (JSON)
modpipe:{run}:artifacts:data:{id}StringContent of an artifact that fits in one chunk (including empty artifacts)
modpipe:{run}:artifacts:data:{id}:chunk:{n}StringChunks of a larger artifact
modpipe:{run}:artifacts:index:{ModuleId}SetArtifact ids uploaded by a module
modpipe:{run}:artifacts:modulesSetModules that uploaded artifacts in the run, used by run data cleanup

The braces around the run identifier form a Redis Cluster hash tag, so every key of a run maps to one slot. Module cache entries are not run-scoped; they use {KeyPrefix}:module-cache:v2:{fingerprint}:..., where the braced fingerprint is the hash tag that keeps an entry's metadata and chunks in one slot.

All storage keys have the configured TTL applied, so they are automatically cleaned up even if the pipeline crashes. Run data cleanup deletes a finished run's keys before the TTL expires.

SignalR Coordinator​

AddSignalRDistributedCoordinator makes the master host a SignalR hub. Workers connect to it and pull leases, publish results and observe cancellation through hub invocations, so a worker that reconnects invokes again and never misses a broadcast.

builder.AddSignalRDistributedCoordinator(options => options with
{
ListenUrl = new Uri("http://0.0.0.0:5099"),
AdvertisedUrl = new Uri("https://pipeline-master.example.com"),
AccessToken = Environment.GetEnvironmentVariable("PIPELINE_HUB_TOKEN"),
});
PropertyTypeDefaultDescription
ListenUrlUrihttp://localhost:5099URL the master binds. Port 0 lets the operating system choose a port.
AdvertisedUrlUri?nullURL workers connect to. The master advertises it through discovery; workers without discovery connect to it, or to ListenUrl when it is unset.
HubPathstring/pipeline-hubHub path, starting with /.
AccessTokenstring?nullToken workers present to the master. When unset, the master generates one whenever it is reachable beyond the machine (a tunnel or a non-loopback ListenUrl) and shares it through master discovery; without discovery such a master fails at startup until every process configures a token. The token is never logged.
ConnectionTimeoutTimeSpan00:02:00How long a worker keeps trying to connect.
MaxReconnectAttemptsint5Automatic reconnect attempts with exponential backoff; 0 disables reconnecting. A worker that stays disconnected for longer than WorkerTimeout loses its leases.
KeepAliveIntervalTimeSpan00:00:05Ping interval on both sides.
PeerTimeoutTimeSpan00:00:15Silence after which a side considers its peer gone; at least twice KeepAliveInterval.
MaxMessageSizeByteslong1048576Largest SignalR message, such as a module result.
Tunnel.EnabledboolfalseStart a cloudflared quick tunnel to the bound URL and advertise its public URL.
Tunnel.CloudflaredPathstringcloudflaredPath to the cloudflared binary.
Tunnel.StartupTimeoutTimeSpan00:00:30How long to wait for the tunnel URL.

The hub rejects registrations for another RunId, a second live worker with the same WorkerId, and every call from a connection that has not registered; results and heartbeats must come from the registered worker. Detailed hub errors are disabled, and the tunnel URL is logged only at Debug.

Redis Master Discovery​

AddRedisMasterDiscovery stores the SignalR master's MasterEndpoint, its URL and access token, in Redis so workers can find it. Discovery opens its own lazily connected Respire connection; it neither uses nor replaces a Redis client registered by the application.

PropertyTypeDefaultDescription
ConnectionStringstringEmptyExplicit Respire connection string; required unless ConfigureConnection supplies endpoints or both REST options are set. Unknown options such as abortConnect fail validation.
ConfigureConnectionFunc<RespireOptions, RespireOptions>?nullTransforms the TCP configuration during validation, for example credentials or TLS; must leave at least one endpoint. The configuration is reused when connecting. Defaults to Respire's RespireRetryPolicy unless one is set. Ignored for REST.
RestUrlUri?nullAbsolute HTTPS Upstash REST endpoint (HTTP only on loopback); configure together with RestToken.
RestTokenstring?nullUpstash REST token; configure together with RestUrl.
KeyPrefixstringmodpipePrefix of the endpoint key {KeyPrefix}:{RunId}:master-endpoint.
TimeToLiveTimeSpan01:00:00How long the advertised endpoint is kept. Run data cleanup deletes it earlier.
DiscoveryTimeoutTimeSpan00:02:00How long workers wait for the endpoint.
PollIntervalTimeSpan00:00:00.500How often workers check for the endpoint.

Because the stored endpoint includes the access token, only the run's processes should be able to read the discovery Redis database.

Connection String Examples​

The Redis coordinator, artifact store, module cache and master discovery parse connection strings with Respire, which accepts StackExchange.Redis-style strings but rejects options it does not support, such as abortConnect.

Local Redis:

localhost:6379

Redis with password:

redis-host:6379,password=mysecret

Redis with TLS (e.g., Upstash, Redis Cloud):

redis-host:6380,password=mysecret,ssl=true

Multiple endpoints (Redis Cluster):

host1:6379,host2:6379,password=mysecret,cluster=true

Respire requires cluster=true (or serviceName for Sentinel) when a connection string lists more than one endpoint. See the Respire connection docs for all connection string options.

V4 endpoint migration​

SignalRDistributedOptions.ListenUrl, S3StorageOptions.ServiceUrl, and RedisDiscoveryOptions.RestUrl now use Uri instead of string, matching SignalRDistributedOptions.AdvertisedUrl. In C# configuration callbacks, wrap endpoint strings in new Uri("https://host.example"). Endpoints must be absolute HTTP or HTTPS URLs; relative URLs and other schemes fail options validation. Redis REST endpoints require HTTPS except on loopback because requests include a bearer token. They may include a path prefix, but must not contain a query string or fragment. Empty or whitespace optional S3/Redis endpoints are treated as omitted.

ListenUrl represents one endpoint, not a semicolon-separated list of Kestrel addresses. Kestrel wildcard hosts such as http://+:5099 and http://*:5099 are not valid Uri values. To listen on all IPv4 interfaces, use new Uri("http://0.0.0.0:5099") and set AdvertisedUrl to an address workers can reach.

Configuration files and environment variables still contain URL strings; normal configuration binding converts them to Uri. SignalR retains its http://localhost:5099 default and supports port 0 for an assigned port. S3's service endpoint and Redis's REST endpoint remain optional: null selects the AWS region endpoint or Redis TCP connection, respectively. Configure a Redis REST token together with its REST endpoint.