Skip to content
← All posts
· Delta1 Labs Licensing.NETGuide

Licensing containers and autoscaled .NET services with Keyright

Machine-bound licensing assumes a machine: a stable box with a fingerprint you can lease a seat to. A container has none of that — the image is identical across replicas, the host is ephemeral, and an autoscaler spins instances up and down faster than any activation flow can keep up. Here is how to license a horizontally-scaled .NET service with Keyright: activate once at the deployment boundary, ship a signed lease into every replica, validate offline with no per-instance network call, and decide up front whether a seat means a deployment or a running instance.

Every licensing SDK that binds to a machine assumes there is a machine: a durable host with a fingerprint — a CPU id, a registry GUID, a MAC address — that you can lease a seat to and recognize again tomorrow. That assumption is the foundation of node-locking, and it is completely false inside a container.

A replica is not a machine. Ten replicas of your service run the same image, so a naive fingerprint computed from the environment collides and they all look like one host — or worse, the fingerprint keys off something per-container and each replica looks like a brand-new machine that needs its own seat. The host underneath is ephemeral: the fingerprint you activated against this morning is gone after the afternoon deploy. And an autoscaler creates and destroys instances faster than any activation handshake can track, so a per-replica activation flow either exhausts your seat count in minutes or strands dead leases on hosts that no longer exist.

This post is how to license a horizontally-scaled .NET service with Keyright without any of that happening. The core move is to stop activating replicas and start activating the deployment — once, at a boundary that is actually stable — then ship a signed lease into every replica that each one verifies offline. We will build that, add a heartbeat variant for when you really do want to meter instances, and wire a custom machine-identity provider so you control exactly what “the same machine” means.

The boundary that is actually stable

The whole problem dissolves once you pick the right unit. There are only two honest answers to “what is a seat here,” and they lead to different wiring:

  • A seat is a deployment. You sell one license per production environment. It scales to three replicas or thirty; that is an implementation detail of the customer’s infrastructure, not a billable event. This is almost always what you want for a server product.
  • A seat is a running instance. You genuinely meter concurrency — N instances may run at once — and you want autoscaling beyond N to be denied or to draw down a pool.

Pick deliberately. The failure mode is picking instance-metering by accident because you lifted an activation flow out of a desktop app, where one install equals one machine equals one seat. On a desktop that identity holds for years. In a cluster it holds for the lifetime of a pod, which might be ninety seconds.

issuing service(activation only)oncebootstrap stepActivateAsync()→ signed leaselease.bin (read-only)replica 1Validate() offline ✓replica 2Validate() offline ✓replica N (autoscaled)Validate() offline ✓one activation · one signed lease · N offline verifications · no per-replica network

Activate once, at the boundary

Activation is the only step that talks to your issuing service, and it is the only step that must not run inside a request-serving replica. Run it once — in a release pipeline, an init job, or a one-shot bootstrap container — and capture the signed lease it returns.

// bootstrap: runs ONCE per deployment, not per replica.
// Output is a signed lease blob that every replica will verify offline.
var client = new KeyrightClient(new KeyrightOptions
{
    LicenseKey = Environment.GetEnvironmentVariable("KEYRIGHT_KEY"),
    // Pin identity to the DEPLOYMENT, not the ephemeral host (see below).
    MachineIdentity = new DeploymentIdentity("prod-eu-west"),
});

ActivationResult result = await client.ActivateAsync();
if (!result.Succeeded)
{
    Console.Error.WriteLine($"activation failed: {result.Status}");
    return 1;
}

// Persist the signed lease where replicas can read it read-only.
await File.WriteAllBytesAsync("/license/lease.bin", result.Lease.ToBytes());
return 0;

result.Lease is the signed artifact: the license payload plus the per-machine lease, signed by your issuing service’s private key and verifiable against the public key compiled into your binary. It carries its own validity window (14 days by default) and the deployment identity it was minted for. Nothing secret lives in it that an attacker could not already read from a running instance, so distributing it as ordinary read-only configuration is fine — it is signed, not confidential.

Verify in every replica, offline

Each replica loads that lease and validates it locally. No ActivateAsync, no issuing-service call, no shared mutable state — just a signature check against the embedded public key.

public sealed class LicenseGate : IHostedService
{
    private volatile LicenseSnapshot _snapshot = LicenseSnapshot.Denied;
    public LicenseSnapshot Current => _snapshot;

    public Task StartAsync(CancellationToken ct)
    {
        var leaseBytes = File.ReadAllBytes("/license/lease.bin");

        var client = new KeyrightClient(new KeyrightOptions
        {
            // Same deployment identity the lease was minted for; the
            // offline check confirms the lease matches this identity.
            MachineIdentity = new DeploymentIdentity("prod-eu-west"),
            LeaseSource = LeaseSource.FromBytes(leaseBytes),
        });

        LicenseInfo info = client.Validate();   // offline signature verification
        _snapshot = info.IsValid
            ? new LicenseSnapshot(true, info.Edition, client.Entitlements(), info.Status)
            : LicenseSnapshot.Denied;

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

Validate() is a local cryptographic operation. It runs in a network-isolated pod, in an air-gapped cluster, and on a scale-from-zero cold start where the issuing service may not even be reachable — which is the entire point of doing activation at the boundary. A replica that cannot reach anything can still prove it holds a valid lease, because proof lives in the signature, not in a callback.

Register it once and read the snapshot everywhere, exactly as you would a single gate in a desktop app:

builder.Services.AddSingleton<LicenseGate>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<LicenseGate>());

// In a request path — read the cached snapshot, never re-validate inline.
app.MapPost("/export", (LicenseGate gate) =>
    gate.Current.Entitlements.IsEnabled("bulk-export")
        ? Results.Ok(RunExport())
        : Results.StatusCode(StatusCodes.Status402PaymentRequired));

Controlling what “the same machine” means

The piece that makes all of this work is the machine-identity provider. Keyright’s default derives an identity from host characteristics — correct for a desktop, wrong for a replica. Override it so you decide the grain. For deployment-scoped licensing, every replica must resolve to the same identity, so the lease minted for the deployment validates in all of them:

public sealed class DeploymentIdentity : IMachineIdentityProvider
{
    private readonly string _deployment;
    public DeploymentIdentity(string deployment) => _deployment = deployment;

    // Stable across replicas and restarts; independent of the ephemeral host.
    // Source it from a value that is constant for the deployment, e.g. an
    // injected environment name, NOT from hostname/CPU/MAC.
    public string GetMachineId() =>
        Convert.ToHexString(
            SHA256.HashData(Encoding.UTF8.GetBytes($"deployment:{_deployment}")));
}

The rule is in the comment: derive the id from something constant for the deployment (an environment name, a tenant id you inject) and never from Dns.GetHostName(), the container id, or any per-replica value. If the id varies per replica, you are back to instance-metering whether you meant to be or not.

When you really do want to meter instances

Sometimes concurrency is the product and you want exactly N instances to run. Then you lean on the lease’s short life instead of fighting it. Mint per-instance leases with a short validity and a heartbeat; a replica that scales in simply stops renewing, and its seat is reclaimed when the lease lapses — no explicit deactivation call that a killed container would never get to make.

var client = new KeyrightClient(new KeyrightOptions
{
    LicenseKey = key,
    MachineIdentity = new InstanceIdentity(),     // distinct per replica, on purpose
    LeaseDuration = TimeSpan.FromMinutes(10),      // short, so scale-in self-heals
});

// A background heartbeat renews while the instance lives.
// Miss two renewals (e.g. the pod was killed) and the seat frees itself.
_ = Task.Run(async () =>
{
    using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
    while (await timer.WaitForNextTickAsync(ct))
        await client.RefreshAsync();   // the only runtime call to the issuing service
});

This is the one topology where replicas talk to the issuing service at runtime, and it is a deliberate trade: you accept a renewal dependency in exchange for accurate concurrency accounting. Everything the gate serves still reads the cached snapshot; the heartbeat only swaps that snapshot’s reference on a successful refresh, and a transient refresh failure rides the lease’s remaining validity rather than denying service on the first blip — the same grace reasoning you would apply to any offline-first gate.

What to take away

Containerized licensing goes wrong when a replica is mistaken for a machine. Fix the unit first — deployment or instance — and the wiring follows. For the common case, a deployment is the stable boundary: activate once there, mint one signed lease, and let an offline Validate() in every replica do the rest, with a machine-identity provider that pins all replicas to one identity. Reserve per-instance leases and heartbeats for when concurrency is genuinely what you sell. Either way, the request-serving replicas stay offline-first and the issuing service stays off the hot path — which is what lets a service scale from zero to thirty and back without touching your seat count or your latency.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.