Node-locked licensing rests on a comfortable assumption: that a machine has a stable identity. Hash a few durable hardware attributes, bind the license to that fingerprint, and one license maps to one machine for the life of the activation. That assumption holds for a workstation or a long-lived VM. It falls apart the moment you put the app in a container.
A container does not have a durable identity. It has a fresh one, minted on every start. /etc/machine-id is regenerated, the MAC address is synthetic and reassigned, the hostname is a random short hash, and the filesystem is thrown away on exit. To a fingerprint-based licensing client, every restart, every redeploy, and every scale-up event looks like a brand-new machine. Bind naively and you will exhaust your activations in a week of ordinary deploys — or worse, a rolling update will roll out a fleet of containers that all fail the node-lock at once.
Key takeaway: In containers, stop deriving machine identity from the container. Either bind the license to a stable identity the operator injects (an env var or mounted secret that survives restarts and rollouts), or — for elastic, horizontally scaled replicas — use concurrent lease licensing where each replica checks out a short-lived seat and returns it automatically when it dies. Both let you validate at startup and fail closed without burning activations on every deploy.
Why container fingerprints are useless
It is worth being precise about why the usual fingerprint inputs betray you, because the failure is silent until your seats are gone.
-
/etc/machine-idis populated by the init system on first boot. In a container there is no persistent first boot — the file is empty or freshly generated in the image layer, so either every container shares one baked-in value (useless for distinguishing nodes) or each gets a new one (useless for stability). - MAC addresses belong to virtual interfaces the runtime creates and destroys with the container. They are not the host NIC and they do not persist.
-
Hostname defaults to the truncated container id — different on every
docker runand every orchestrator-scheduled pod. - Disk and volume serials point at an overlay or ephemeral volume that is discarded on exit.
The orchestrator makes this worse on purpose: its whole job is to treat instances as cattle, rescheduling and replacing them freely. A fingerprint that changes on reschedule is not identifying a machine; it is identifying a container lifetime, which is exactly the wrong granularity for a license.
The two diagrams below contrast what happens across a single rolling deploy.
Strategy 1 — bind to an injected deployment identity
The clean fix for a single logical deployment (one licensed service, however many times it restarts) is to stop letting the SDK guess the machine identity and instead hand it one that the operator controls. The operator sets a value — call it KEYRIGHT_DEPLOYMENT_ID — that stays constant across restarts and rollouts of the same service. The container reads it at startup and passes it to the SDK as the bind identity.
KeyrightOptions exposes a MachineId for exactly this. When set, the SDK binds activation to your value and never touches the container's fingerprint:
using Delta1Labs.Keyright;
static string RequireDeploymentId()
{
// Prefer a mounted secret file; fall back to an env var. Never derive
// this from the container — that is the whole point.
var path = Environment.GetEnvironmentVariable("KEYRIGHT_DEPLOYMENT_ID_FILE");
if (!string.IsNullOrEmpty(path) && File.Exists(path))
return File.ReadAllText(path).Trim();
var id = Environment.GetEnvironmentVariable("KEYRIGHT_DEPLOYMENT_ID");
if (string.IsNullOrWhiteSpace(id))
throw new InvalidOperationException(
"KEYRIGHT_DEPLOYMENT_ID is not set. A containerized deployment must " +
"inject a stable license identity; refusing to start unbound.");
return id.Trim();
}
static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
Product = "checkout-service",
PublicKeyBase64 = "MIIBIjANBgkq...", // verify-only public key
ServiceUrl = "https://keyright.example.com", // your self-hosted instance
MachineId = RequireDeploymentId() // the injected stable identity
});
With MachineId pinned, the activation flow is ordinary — but now it is stable:
// Run once at startup, before serving traffic.
LicenseInfo info = await License.ActivateAsync(
Environment.GetEnvironmentVariable("KEYRIGHT_LICENSE_KEY")!, ct);
if (!info.IsLicensed)
{
// Fail closed. See "Failing closed cleanly" below.
logger.LogError("License not active: {Reason}", info.Reason);
return StartupResult.Unlicensed(info);
}
logger.LogInformation("Licensed: {Tier}, bound to {Id}", info.Tier, info.MachineId);
Because every container in this deployment activates against the same MachineId, Keyright recognises a restart or a rolled-out replacement as a re-activation of a node it already knows. Re-activating an already-bound identity is idempotent: it refreshes the lease and consumes no additional seat. A hundred restarts cost one seat. The signed lease the SDK caches is written to the container filesystem and is naturally discarded on exit — which is fine, because the next container simply re-activates against the stable id and gets a fresh lease back.
Where the identity should come from
The injected id must be stable and operator-controlled, not derived from anything the runtime regenerates. Good sources: a value the operator sets per service in their deployment config, a mounted secret, or your orchestrator's stable workload identity (not the pod name — the workload name). Treat the license key the same way: mount it as a secret, never bake it into the image layer.
Strategy 2 — concurrent leases for scaled replicas
Strategy 1 is right when "the deployment" is one licensed node. It is wrong for an elastic replica set, because a single injected id would make twenty replicas look like one node — you would be under-counting, not licensing honestly. When you genuinely run N interchangeable replicas and want to license concurrency, use floating leases instead.
Each replica acquires a short-lived seat lease at startup, renews it on a heartbeat while it runs, and the lease expires on its own if the replica is killed or scaled down. You buy a concurrency ceiling; the orchestrator can scale between zero and that ceiling freely, and a SIGKILLed replica never leaves a seat stuck.
static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
Product = "checkout-service",
PublicKeyBase64 = "MIIBIjANBgkq...",
ServiceUrl = "https://keyright.example.com"
// No MachineId here: each replica is a distinct lease holder, not a bound node.
});
SeatLease lease;
try
{
// A fresh instance id per replica is fine and expected — the seat is tied
// to a live lease, not to a durable fingerprint.
lease = await License.AcquireSeatAsync(
Environment.GetEnvironmentVariable("KEYRIGHT_LICENSE_KEY")!, ct);
}
catch (SeatsExhaustedException)
{
// Concurrency ceiling hit. Fail closed: do not report healthy.
logger.LogError("All concurrent seats are in use; refusing to start.");
return StartupResult.Unlicensed(reason: "seats-exhausted");
}
// Keep the lease alive for the life of the replica.
var keepAlive = new SeatKeepAlive(License, lease);
hostLifetime.ApplicationStopping.Register(() =>
License.ReleaseSeatAsync(lease).GetAwaiter().GetResult()); // prompt check-in
The crash case is the one containers get for free here: if the pod is OOMKilled or evicted, ReleaseSeatAsync never runs, the heartbeat stops, and the lease reaches its TTL and is reclaimed server-side. No stuck seat, no activation burned. (The lease lifecycle, heartbeat timer and graceful all-seats-in-use retry are covered in depth in floating concurrent seats.)
Which strategy fits:
| Inject a stable id (Strategy 1) | Concurrent leases (Strategy 2) | |
|---|---|---|
| Model | One licensed node, many restarts | N interchangeable replicas |
| Bound to | An operator-injected identity | A live lease per replica |
| Scale event | Same id → idempotent re-activation | Each replica takes/returns a seat |
| Needs the service | At activation / lease refresh | Continuously, to count concurrency |
| Good for | A singleton or active-passive service | Elastic, horizontally scaled sets |
Failing closed cleanly
A container must never start serving traffic on an unresolved license, and it must never crash-loop on a licensing hiccup either. The Keyright SDK makes the policy yours to set: Validate() and ActivateAsync() do not throw for ordinary failures — a missing key, an unreachable service, an exhausted seat, a revoked license — they return a LicenseInfo (or raise the typed SeatsExhaustedException for the lease path) that you inspect and act on. Decide the policy once, at startup, before the first request is served.
There are two sane closed states, and you should pick deliberately:
-
Degrade to Community Edition. If your product has a free tier, an unlicensed container can run the Community Edition feature set.
Validate()returns aLicenseInfoin theCommunityedition carrying the reason; gate paid entitlements offinfo.Entitlementsand run. - Refuse to be healthy. For a product with no free path, do not exit the process (that just crash-loops and spams the issuing service). Instead, start the process but report unhealthy on your readiness probe so the orchestrator holds traffic back, and log the reason loudly. This gives an operator a window to fix the injected key or restore connectivity without a restart storm.
app.MapHealthChecks("/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready")
});
// A readiness check that reflects the license decision made at startup.
services.AddHealthChecks().AddCheck("license", () =>
LicenseState.Current.IsLicensed
? HealthCheckResult.Healthy($"Licensed: {LicenseState.Current.Tier}")
: HealthCheckResult.Unhealthy($"Unlicensed: {LicenseState.Current.Reason}"),
tags: new[] { "ready" });
Gate the actual licensed functionality the same way everywhere — check an entitlement, never a cached boolean from an earlier run:
LicenseInfo info = License.Validate(); // offline, no network, never throws
bool canUsePremiumRouting = info.Entitlements.IsEnabled("premium-routing");
long maxThroughput = info.Entitlements.GetLimit("max-rps", 50);
Wiring it in the image and compose file
The container image stays clean: no license key, no identity baked in. Everything license-related arrives at runtime as environment and secrets the operator controls.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["CheckoutService.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
# No KEYRIGHT_LICENSE_KEY and no KEYRIGHT_DEPLOYMENT_ID in the image.
# They are injected at run time. Fail closed if the id is missing (see startup code).
ENTRYPOINT ["dotnet", "CheckoutService.dll"]
The compose service is where the stable identity and the key material actually come from. The key is a mounted secret; the deployment id is a plain, operator-set env var that stays constant across up, restart and redeploy:
services:
checkout:
image: registry.example.com/checkout-service:2.3.0
environment:
# Stable across restarts/redeploys — operator-owned, NOT container-derived.
KEYRIGHT_DEPLOYMENT_ID: "checkout-svc-prod"
KEYRIGHT_LICENSE_KEY_FILE: "/run/secrets/keyright_key"
ServiceUrl: "https://keyright.example.com"
secrets:
- keyright_key
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/ready"]
interval: 15s
timeout: 3s
retries: 5 # readiness reflects the license state; unlicensed = not ready
deploy:
replicas: 1 # Strategy 1: one bound node. For Strategy 2, raise replicas
# and drop KEYRIGHT_DEPLOYMENT_ID (use leases instead).
secrets:
keyright_key:
file: ./secrets/keyright.key
Two rules make this robust. First, the identity and the key are never in the image — an image is copied, retagged and shared, and anything baked into it leaks. Second, for Strategy 1 the KEYRIGHT_DEPLOYMENT_ID is set once by the operator and treated as part of the deployment's configuration, so it rides through every restart and rolling update unchanged. For Strategy 2, you omit it entirely and raise the replica count — the lease model counts concurrency instead.
Summary
Containers broke the one assumption node-locking was built on — a stable machine identity — and the fix is to stop pretending the container has one. For a single licensed service, inject a stable KEYRIGHT_DEPLOYMENT_ID the operator owns and bind to it with KeyrightOptions.MachineId, so restarts and rollouts re-activate the same node idempotently and never burn a seat. For elastic replica sets, license concurrency with floating leases, so each replica checks out a short-lived seat and a killed replica returns it on its own. Either way, resolve the license at startup, keep the key and identity out of the image, and fail closed on your readiness probe rather than crash-looping. The Keyright.NET SDK gives you both paths — MachineId-pinned activation and lease-based concurrency — with fail-closed LicenseInfo everywhere in between, so an orchestrator can reschedule your app all day without touching your activation count.
Top comments (0)