Pular para o conteúdo principal

Deploying on Azure App Service — cluster mode

Zero to a two-instance cluster, verified on the dashboard's Instances view.

This is the walkthrough for the one supported multi-instance topology: an Azure Web App running the Linux container image, scaled out on one App Service Plan. The mechanisms coordinate through the operational store and the work share and are host-agnostic, but the supported topology is exactly this one — two on-premises VMs against one SQL Server would run the same code and are undocumented, untested and unsupported.

Read the limits before you start

High availability and its limits is the list of what this topology does not give you — stop-the-world upgrades, no deployment slots, rate budgets that multiply, metrics scraping that lands on an arbitrary instance. Every item on it is cheaper to know now than to discover in a change window.

Single-instance installs are elsewhere and unaffected: Linux systemd, Windows Service, Docker and the foreground console are in Installation, and nothing on this page applies to them. Cluster:Enabled defaults to false, and off is byte-for-byte the single-instance product.

The shape of it

The supported Azure App Service cluster topology for Bulk Signer

Every arrow is an outbound HTTPS call on port 443, including the share: this product reaches Azure Files through the storage SDK and never over SMB, so there is no port 445 anywhere in that picture, no mount unit and no host package.

Read the two arrows into Key Vault together, because they are the one asymmetry on the page. Six grants are held by the web app's managed identity; exactly one is held by an Entra app registration — the right to sign with the key — and it is the only credential here that is a secret somebody has to rotate. Step 3 is where that comes from and why.

Before you start

Six things must be settled before the first boot. Four of them are a boot refusal, a fatal error or a red readiness row rather than a runtime surprise; the last two are recommendations that this topology makes much stronger than they are elsewhere.

#WhatWhy cluster mode requires it
1An Azure SQL database, plus a login in db_datareader + db_datawriter + db_ddladminThe store is the cluster's coordination point: job claims, the pause flag, the heartbeat table and the session key ring all live there. Cluster:Enabled = true with Database:Provider anything but SqlServer is refused at boot. This service creates its tables, not its database.
2An Azure Files share (SMB) for the work tree, with every watched input directory created inside itStorage:Provider and every Storage:Inputs[] entry must resolve to AzureFiles, refused at boot otherwise. The local store's lease is in-process bookkeeping that excludes nothing outside its own process, and a folder local to one instance is invisible to its siblings. An NFS share is refused by name.
3A decision between the two certificate sources that are not per-hostPfx read from a blob, or AzureKeyVaultPkcs11 and WindowsStore are refused at boot: a token or a machine store lives on one machine, and cluster instances are fungible. That leaves two, and neither is the obvious winner — step 3 is the choice and what it costs. Nothing has to exist yet.
4The container image in a registry the web app can pull fromLacuna publishes it to its private Docker image repository; step 1 copies the tag you were issued into a registry of your own, which is what makes a managed-identity pull possible.
5An Azure Storage table for the log sinkStrongly recommended rather than required. The container's disk is ephemeral, so rolled log files are discarded on every recycle; leaving Logging:AzureTable:Enabled = false in cluster mode logs a Critical at startup and starts anyway. Nothing prunes that table — read Retention and schedule the pruning script before you turn the sink on.
6An Application Insights resourceAlso recommended rather than required, and for a reason specific to this topology: /api/metrics behind the load balancer reaches an arbitrary instance, so Prometheus scraping has no continuity here. The Application Insights distro is instance-aware natively and is the recommended cluster observability path — see Telemetry.

Give the web app a system-assigned managed identity before you type anything else, because almost everything here can then authenticate without a secret: Credential = ManagedIdentity for the Azure Files share, Authentication=Active Directory Managed Identity in the SQL connection string, and the same mode for the certificate blob and the log table. Each is a separate grant even though one identity holds them all — see Security.

One credential cannot be a managed identity

Signing:…:Certificate:AzureKeyVault requires AppId and AppSecret; the block has no Credential key and no managed-identity mode, so under that source the right to sign is held by an Entra app registration with a client secret. That is the one secret this topology cannot design away, and step 3 weighs it against what the vault buys. It is worth knowing before you choose a certificate source.

Sizing the plan and the database

Two of the prerequisites above have a size as well as an existence, and one number an operator usually knows in advance sizes both: signatures a day. The table is a starting point rather than a benchmark result — what it cannot know is your files, and those move the answer further than the volume does. A CAdES signature over a CNAB240 remessa and a PAdES signature over a 300-page scanned PDF are the same one job to this product and nothing like the same work.

Signatures per dayApp Service PlanAzure SQL — max vCores
0 – 1,000P0 V3 × 12
1,001 – 5,000P1 V3 × 14
5,001 – 20,000P1 V3 × 26
20,001 – 100,000P2 V3 × 28
100,001 – 1,000,000P2 V3 × 28

× N is the instance count — --number-of-workers on one plan, never a second plan. Whichever row you are on, step 2 still starts you at one worker and step 7 is where you scale to the number above: the first boot is where every refusal fires, and reading one instance's console is easier than reading two.

The last two rows are the same hardware, and that is the table's most informative line. Somewhere around 100,000 signatures a day the web tier stops being the constraint. A job is one signature, and what bounds it from there is the certificate source — a Pfx already in memory, or a round trip to Key Vault — together with the share's I/O. Ten times the volume on the same plan is therefore a statement about where the work actually is, not headroom the plan was holding in reserve. At the top of that row, size the vault round trip and Pipeline:MaxConcurrency first, and grow the plan last.

A larger SKU does not sign more files at once. Pipeline:MaxConcurrency is the only thing that does, it is per instance, and step 3 sets it to 4 — so a × 2 row is eight signatures in flight, and eight is the number the certificate source has to survive rather than the configured four. High availability is where that same multiplication reads as a limitation instead of a feature.

The two × 1 rows do not need this page. Cluster mode is legal at one instance — step 6 boots exactly that way — but at one instance nothing it coordinates is doing any work, and a deployment that stays there permanently is paying for an Azure SQL database and an Azure Files share it could have done without. Under 5,000 signatures a day, read Installation first and come back when the volume, or a second instance, is the reason rather than the platform.

What "max vCores" means, and why auto-pause never fires

A max vCore figure describes Azure SQL's serverless compute tier, where that number is the ceiling the database may scale up to (--max-capacity); on the provisioned tier read the same figure as the vCore count, since there is nothing to scale. Do not budget for auto-pause savings either way. This product polls its store continuously, and in cluster mode every instance also writes a heartbeat every Cluster:HeartbeatSeconds — 15 by default — so the database is never idle for anything like the hour auto-pause requires. Size it as a database that is always up.

None of this is a throughput argument for SqlServer. The vCore column sizes a database whose provider Before you start already made compulsory here; it is not a reason to move a single-instance install off SQLite, whose ceiling is not this pipeline's either — that decision is in Installation, on its own terms.


1. Import the image

Lacuna publishes the container image to its private Docker image repository and issues you a registry username and access token for it — see Obtaining the product. Nothing is built here: you copy the tag you were told to install into a container registry of your own.

az group create --name bulksigner-rg --location brazilsouth
az acr create --name <registry-name> --resource-group bulksigner-rg --sku Basic
az acr import --name <registry-name> \
--source <lacuna-registry>/bulksigner:<version> \
--image bulksigner:<version> \
--username <registry-username> --password <registry-token>

az acr import is a registry-to-registry copy performed by the service: the layers never touch your workstation and no local Docker daemon is involved. Skip either create line if the group or the registry already exists — the import is the part that has to happen, and Basic is enough registry for one image pulled by a handful of instances.

Import one explicit <version> tag rather than latest. The tag you name here is the tag step 2 pins the web app to, and on this topology an upgrade is a stop-the-world window — something you schedule, not something a platform recycle does to you.

Why a registry of your own, rather than pointing the web app straight at Lacuna's. Two reasons, and the first shapes the rest of this page: the app pulls with its managed identity (AcrPull, step 2), and an identity in your tenant can only be granted a role on a registry in your tenant — a foreign registry means a username and password in app settings, which is one more secret to rotate and the one this design otherwise spends effort eliminating. The second is availability: App Service pulls the image again on every instance it starts, so scale-out (step 7) and platform recycles would otherwise depend on a registry outside your subscription answering at that moment.

2. Create the plan and the app — one instance first

az appservice plan create --name bulksigner-plan --resource-group bulksigner-rg --is-linux --sku P1V3 --number-of-workers 1

P1V3 above is the example's SKU, not a recommendation — Sizing the plan and the database is which one your volume asks for.

Basic (B1) is the floor, because it is the first tier that scales out at all — and it caps at three instances, which also caps what Health check can do for you, since rerouting away from an unhealthy instance requires somewhere to reroute to. Premium v3 is the production answer. Start at one worker deliberately: the first boot is where every refusal fires, and reading one instance's console is easier than reading two.

az webapp create --name bulksigner --resource-group bulksigner-rg --plan bulksigner-plan --container-image-name <registry-name>.azurecr.io/bulksigner:<version>

bulksigner:<version> is the repository and tag step 1 imported into — your registry, not Lacuna's. Pin that tag rather than latest, so an upgrade is something you do rather than something a restart does.

az webapp identity assign --name bulksigner --resource-group bulksigner-rg

Grant that identity AcrPull on the registry, Storage File Data Privileged Contributor on the share, a SQL login mapped to it in db_datareader + db_datawriter + db_ddladmin, Storage Blob Data Reader on the container holding the certificate, and Storage Table Data Contributor on the log table's account. Step 3 adds the sixth and last of them.

What does not go on that list is the right to sign

Under AzureKeyVault the vault key is reached by the Entra app registration named in AppId, never by this identity — so get + sign granted here would be granted to the wrong principal and the boot would still fail, with a message about the vault that says nothing about why. The one grant this identity does take on that vault is Key Vault Secrets User, for reading the app registration's own secret back out as an app setting.

Then tell the app to pull with that identity rather than a registry password:

az resource update --ids $(az webapp config show --name bulksigner --resource-group bulksigner-rg --query id -o tsv) --set properties.acrUseManagedIdentityCreds=True

Without that last line the pull falls back to registry credentials the app does not have, and the first symptom is a container that never starts — with nothing in the application log, because there is no application yet.

3. The signing key lives in a vault

Two sources survive the boot refusals in Before you start, and this is the step that picks between them. They are genuinely co-equal — the choice is a policy one, not a technical one, and each concedes something the other keeps.

Pfx read from a blobAzureKeyVault
Where the private key isA .pfx in Blob Storage, read into the instance's memory at bootInside the vault, permanently. Each signature is a remote call and the key never leaves Azure
CredentialThe web app's managed identity, Storage Blob Data Reader. No secretAn Entra app registration with a client secret, plus a managed-identity grant to read that secret back
Signing latencyNone beyond local cryptoA vault round trip per job
If Azure is unreachableAlready signed; the pipeline keeps signingEvery job fails until the vault answers
AuditThis product's job history, and nothing elseEvery signature is a logged Key Vault operation — an independent count to reconcile against the job history

The honest summary is that AzureKeyVault buys a key that cannot be exfiltrated from a compromised instance, and pays for it with the one secret this topology otherwise has none of. If a client secret in the app settings is not acceptable to you, Pfx-as-blob is the better answer than a software-backed vault key — a software key is FIPS 140-2 Level 1, which concedes most of the argument for the vault while keeping all of its cost. The rest of this section assumes you chose the vault; for Pfx, everything you need is Certificates plus the blob upload below.

Provision the vault, the key and the app registration

az keyvault create --name bulksigner-kv --resource-group bulksigner-rg --location brazilsouth --sku premium --enable-rbac-authorization true

Premium, because the key should be HSM-backed. Standard gives you a software key, and the paragraph above is why that is the wrong end of this trade.

An ICP-Brasil certificate arrives as a PFX, so the key is imported rather than generated. Import-PfxToKeyVault.ps1 does the whole import in one pass — writes the public .cer, imports the private half as a key object, registers the Entra application, creates its secret, grants it sign, and runs the same public-key pairing check the service performs at boot, so a mismatch surfaces here rather than at first start:

pwsh Import-PfxToKeyVault.ps1 -PfxPath ./signer.pfx -VaultName bulksigner-kv -KeyName bulksigner-signing-key -AppDisplayName bulk-signer-prod -Destination HSM -GrantScope Key -SecretValidityYears 2
A key object, not a certificate object — and the difference is the whole point

Key Vault will hold this PFX either way, but a certificate imported from a PFX is marked exportable: anyone holding secrets/get can download the complete PFX from the secret backing it. A key object never returns private material under any permission. That is why this product's source is key-only, and why the public certificate has to be supplied separately.

The script prints the client secret once and writes it nowhere. Capture it now.

Put the certificate where an instance can read it

The script leaves the .cer on your workstation, which is no use to a container whose disk is recycled with it. Upload it and let the managed identity read it:

az storage blob upload --account-name contosocerts --container-name certificates --name signer.cer --file ./signer.cer --auth-mode login

Grant that identity Storage Blob Data Reader on the container — the fourth of the grants step 2 listed. CerPath is still available and points at a local file, but on this topology it means baking the certificate into the image, which couples an annual certificate renewal to an image rebuild and a stop-the-world deploy. Use the blob.

The settings

These are app settings like any other, set here rather than in step 4 because every value in them was produced by the two commands above:

az webapp config appsettings set --name bulksigner --resource-group bulksigner-rg --settings \
Signing__Profiles__0__Certificate__Source=AzureKeyVault \
Signing__Profiles__0__Certificate__AzureKeyVault__Endpoint='https://bulksigner-kv.vault.azure.net/' \
Signing__Profiles__0__Certificate__AzureKeyVault__KeyName=bulksigner-signing-key \
Signing__Profiles__0__Certificate__AzureKeyVault__AppId=99990000-aaaa-bbbb-cccc-ddddeeeeffff \
Signing__Profiles__0__Certificate__AzureKeyVault__AppSecret='@Microsoft.KeyVault(VaultName=bulksigner-kv;SecretName=bulksigner-app-secret)' \
Signing__Profiles__0__Certificate__AzureKeyVault__Blob__Url='https://contosocerts.blob.core.windows.net/certificates/signer.cer' \
Signing__Profiles__0__Certificate__AzureKeyVault__Blob__Credential=ManagedIdentity \
Pipeline__MaxConcurrency=4

The 0 is the profile's index in Signing:Profiles[] and it is positional — reordering that array silently repoints these settings at a different profile. Endpoint must be an absolute https:// URL; a bare DNS name is refused at boot naming the key.

Blob and CerPath are exclusive — both is refused at boot, and so is neither

That refusal is deliberate rather than fussy: two valid certificates for one vault key would both pass the pairing check, so resolving a conflict by precedence would make which certificate signed depend on network weather, unauditably. Note also that Blob inherits nothing from the vault block above it, even though both name Azure — the vault credential grants use of a key, the blob credential grants read of one object, and they are configured separately on purpose.

One vault, two objects, two credentials

AppSecret above is an App Service Key Vault reference, which is the one place on this page where Key Vault appears in both of its roles at once. It looks circular and is not:

Two principals, two Key Vault objects, two grants

  • The web app's managed identity resolves the reference and gets the secret back — a Key Vault Secrets User grant, on the secret.
  • That secret is then what the PKI connector presents to reach the key — a Key Vault Crypto User grant held by the app registration, on the key, scoped by -GrantScope Key above.

Two principals, two objects, two grants. The same vault holds both because a second one would buy no isolation — anyone who can read the secret can use the key regardless — and would only add a resource. Step 4's note that secrets belong in Key Vault references is about this mechanism; the key itself is never an app setting.

Latency is the constraint here, not throttling

Certificates warns that sustained concurrency draws HTTP 429s, which arrive as failed jobs rather than hangs. Worth knowing, and worth the scale: Key Vault allows 2,000 transactions per 10 seconds against an HSM RSA-2048 key — roughly 200 signatures a second, against a product that performs one signature per job. You will meet the round-trip latency long before the ceiling.

The cluster-specific point is the inversion of this page's usual theme. Elsewhere on this topology rate budgets multiply with instances, in the attacker's favour; here the budget is per vault and fixed while the consumers multiply. Pipeline__MaxConcurrency=4 is per instance, so two instances is eight signatures in flight against one vault. AzureKeyVault is treated as concurrency-safe and is exempt from the startup warning that covers Pkcs11 and WindowsStore.

Renewal and rotation are change windows

Three things here are read once, at boot, and nothing polls any of them: the certificate bytes in the blob, the vault key named by KeyName, and the client secret behind the Key Vault reference. Changing any one therefore takes a restart — and a restart on this topology is step 8's stop-the-world, not a rolling one and not a slot swap. Plan an ICP-Brasil renewal, and the -SecretValidityYears 2 expiry, as scheduled outages rather than as maintenance you can do at any time.

Renew one artifact without the other and the boot refuses

A new certificate against the old KeyName, or a new key against the old .cer, fails the pairing check — which on this topology means the change window ends with nothing running. Re-run the import script against the new PFX so both artifacts move together, and read the profile row in step 6 before you call the window closed.

What this step assumes about the network

Every command above reaches its service over a public endpoint with a credential, which is true of SQL, Azure Files and the certificate blob on this page as well. Restricting any of them means VNet integration and private endpoints, which is step 9 and is optional.

4. App settings

App Service app settings are per app, not per instance — which is the fact the whole design leans on: every instance is identical by construction, so the cross-instance config-divergence hazards (an encryption password differing between hosts, a profile one instance never heard of) cannot occur here. Set them as environment variables, in the double-underscore form; there is no appsettings.Production.json to mount.

az webapp config appsettings set --name bulksigner --resource-group bulksigner-rg --settings \
WEBSITES_PORT=8080 \
Cluster__Enabled=true \
Database__Provider=SqlServer \
ConnectionStrings__Default='Server=tcp:sqlsrv01.database.windows.net,1433;Initial Catalog=BulkSigner;Authentication=Active Directory Managed Identity;Encrypt=True;' \
Storage__Provider=AzureFiles \
Storage__AzureFiles__AccountName=contosofiles \
Storage__AzureFiles__ShareName=bulksigner \
Storage__AzureFiles__Directory=prod \
Storage__AzureFiles__Credential=ManagedIdentity \
Storage__Inputs__0__Name=remessas \
Storage__Inputs__0__Path=entrada/remessas \
Storage__Inputs__0__PollIntervalSeconds=30 \
Hosting__ForwardedHeaders__Enabled=true \
Hosting__ForwardedHeaders__TrustAnyProxy=true \
Logging__AzureTable__Enabled=true \
Telemetry__Enabled=true

That block is the topology, not the whole configuration. It deliberately omits everything that is the same here as on any other target — the PKI licence, the API key, the signing profiles, the approval pools. The one exception is a profile's certificate, which is emphatically not the same here as on any other target and was set in step 3 a step ago. Take the rest from the appsettings.Example.Azure.json.sample file in the deployment package, which is one worked shape filled in end to end, and translate each key to its double-underscore form (Signing:Profiles[0].Certificate.SourceSigning__Profiles__0__Certificate__Source). A boot with no resolvable certificate is fatal by design: a profile that cannot sign is not representable.

Secrets — Signing__PkiSdkLicense, Auth__ApiKey, ApproverPortal__LinkSecret, any AppSecret — belong in Key Vault references rather than literal app settings, resolved by the web app's managed identity holding Key Vault Secrets User. Step 3 uses exactly this mechanism for the vault's own client secret, and explains there why that is not circular. Every key this product accepts, with its type, default and environment-variable form, is in Configuration.

Five of the settings above are the cluster-specific ones, and each is there for a reason worth knowing:

  • Cluster__Enabled=true is the whole switch. Nothing is inferred from the environment: a deployment that happens to run SQL Server and an Azure Files share does not silently change behaviour on upgrade.
  • Cluster__HeartbeatSeconds / Cluster__StaleAfterSeconds are absent above because the defaults (15 and 60) are the right starting point — four cadences, so three beats have to go missing before an instance is presumed dead. A threshold worth under three cadences is refused at boot naming both keys. Raise StaleAfterSeconds if you meet false deaths; see the wager.
  • Hosting__ForwardedHeaders__* is what makes the rate limiter and the address recorded on an approval read the real client rather than the load balancer. TrustAnyProxy=true is the intended setting here — App Service's front door has no stable address to list — and only here: on a reverse-proxy deployment it means anybody who can reach Kestrel directly can name themselves any client address. An empty trust set is refused at boot rather than read as the wide answer, and setting the framework's own ASPNETCORE_FORWARDEDHEADERS_ENABLED beside it is refused too.
  • WEBSITES_PORT=8080 matches the image's EXPOSE. App Service auto-detects 80 and 8080, so this is belt and braces — and one setting is cheaper than the diagnosis when a later image changes port.
  • Leave Hosting__RequireHttps unset. Terminate TLS at the platform with httpsOnly below. An in-process redirect answers the health-check ping with a 307, which App Service reads as a failure — see the health-check note below.

5. Platform settings

az webapp update --name bulksigner --resource-group bulksigner-rg --set clientAffinityEnabled=true httpsOnly=true
az webapp config set --name bulksigner --resource-group bulksigner-rg --always-on true --generic-configurations '{"healthCheckPath": "/api/ready"}'

ARR affinity stays on. It is on by default and it is a requirement rather than a preference: the dashboard is Blazor Server, and its circuit is a stateful SignalR connection that has to keep landing on the instance that owns it. This is documented rather than engineered around. Note what it does not do — the session cookie itself is shared, because in cluster mode the Data Protection key ring moves into the operational store, so a cookie minted by one instance validates on every other. Sticky sessions are for the circuit; the key ring is for the cookie. Without the key ring, affinity alone would still sign people out intermittently.

Always On stays on. The signing pipeline is a hosted worker, not a request handler. An app the platform unloads while idle is an app that stops picking up files, and nothing about that reads as an error anywhere.

The health check reads the readiness endpoint

/api/ready is anonymous, per-instance, and returns 503 when any of its probes fail — which is precisely the question App Service is asking. Point Health check at it and the platform stops routing to an instance that cannot serve, and eventually replaces one that stays that way.

Three consequences to accept knowingly:

  • Leave WEBSITE_HEALTHCHECK_MAXPINGFAILURES at its default (10). The endpoint is strict — a single missing input folder fails the whole response — so a tight threshold turns a blip into an eviction.
  • A shared dependency failing takes every instance down together. A share that stops answering or a store that was unreachable at boot is not one instance's problem, and the platform's rule is that when all instances are unhealthy none are removed from the load balancer — but replacement still happens, at most one per hour and three per day per plan. The app stays reachable; the instances get recycled underneath you while the real fault is elsewhere. Read Troubleshooting before concluding the platform is at fault.
  • Health check does not follow redirects. This is why Hosting:RequireHttps stays off: with it on and platform httpsOnly off, the ping gets a 307 and the instance is marked unhealthy for a reason that has nothing to do with its health.

6. First boot, on one instance

Start the app and read the console — App Service log streaming is where the boot narration lands:

az webapp log tail --name bulksigner --resource-group bulksigner-rg

What to look for in the Service ready panel:

RowWhat it should say
operational storeSQL Server (sqlsrv01/BulkSigner) — the provider, server and catalogue, never the connection string. A READ_COMMITTED_SNAPSHOT is off warning matters: Azure SQL has it on by default.
storage providerAzureFiles
work share ownerthis cluster (one marker, shared between instances) — the wording that tells you the mode is actually on. Off the switch this row names one instance.
azure sharesreachable. An unreachable share does not stop the host, but it fails /api/ready and ingests nothing until it answers.
forwarded headersthe trust set by name, not just on.
logsthe table sink among the destinations. If it is absent you will also have seen the Critical about rolled log files on an ephemeral disk.
profilethe certificate that actually loaded — cades · cert=AzureKeyVault · blob=contosocerts/certificates/signer.cer · verify=on · …. Both halves are evidence: cert=AzureKeyVault means the vault answered and the key was found, blob=… means the .cer was read and paired against it. This is the line to read after any certificate renewal — it is the only confirmation that both artifacts moved together.

Then GET /api/ready and confirm every check is green, and open System → Instances on the dashboard: one row, badged as the instance you are reading it on, with a Live chip.

Upgrading an existing single-instance deployment rather than building a new one?

Boot once with Cluster:Enabled = false and let recovery run before you turn the mode on. A row left in progress by an older build carries no owner, and under cluster mode nothing will ever sweep it — this boot's subject is its own previous life, a sibling's is theirs, and takeover follows an owner's heartbeat, of which there is none. The same applies to a job dispatched to Lacuna Signer by such a build. Both surfaces say so when they meet one and name this same remedy. It is a one-time concern at the upgrade: the owner is recorded on every claim whether or not the mode is on, and only read under it.

7. Scale to two

az appservice plan update --name bulksigner-plan --resource-group bulksigner-rg --number-of-workers 2

Give it a minute — App Service pings the health-check path to confirm the new instance is ready before routing to it — then reload System → Instances.

What done looks like: two rows, each with a distinct derived identity (the platform's WEBSITE_INSTANCE_ID, which App Service sets on every instance), each Live, both on the same application version, one badged as the instance answering your request. The caption names the cadence and the staleness threshold in force. Refresh a few times: the badge moves between the two rows, because each reload may land on either instance — which is the load balancer doing its job, and the first thing on this page you can see rather than infer.

Then confirm the cluster actually cooperates

The Instances view proves both instances are alive. It does not prove they share the work.

How two instances cooperate through the operational store and the work share

Drop several files into a watched folder at once and read /jobs:

  • Every file becomes exactly one job. Both instances watch every folder, so they race on each arrival; the losing enqueue is refused by the partial unique index over active original paths and answered as AlreadyActive, and a lease conflict on an input is classified expected — not counted against the folder's consecutive-failure breaker, not recorded as an error. Contention here is the system working, not a fault.
  • The jobs are owned by both instances. /api/folders carries an instance field naming which one answered; the job timeline on /jobs/{id} carries the rest.
  • Stop one instance (scale back to one) and its in-flight work is reconciled by the survivor rather than stranded: a job that never reached the sign call is re-enqueued, one past it fails conservatively, an AwaitingSigner job is reassigned. Each takeover writes a JobTakenOver operational event naming both instances. The policy in full, including why "failed" is an honest outcome and not "stuck", is in Operations.

8. Upgrades are stop-the-world

Stop the app, deploy the new image, start it — with one step ahead of those three, because the image comes from Lacuna's registry rather than from a build:

az acr import --name <registry-name> \
--source <lacuna-registry>/bulksigner:<new-version> \
--image bulksigner:<new-version> \
--username <registry-username> --password <registry-token>

az webapp stop --name bulksigner --resource-group bulksigner-rg
az webapp config container set --name bulksigner --resource-group bulksigner-rg \
--container-image-name <registry-name>.azurecr.io/bulksigner:<new-version>
az webapp start --name bulksigner --resource-group bulksigner-rg

Not a rolling restart, and not a deployment-slot swap — a staging slot carrying production's connection string is a second set of instances joining the cluster on a different application version, which is the one shape this design does not support.

The heartbeat's version stamp is the tripwire rather than the guard: a booting instance that sees live heartbeats from a different version logs a Critical and continues. It is deliberately not a refusal — refusing would block instances from coming up for as long as a dead old-version heartbeat took to go stale, which is exactly when an operator needs them up.

The full argument, and the rest of what this topology does not offer, is in High availability and its limits.

9. Hardening the network (optional)

Everything above is a working cluster reached over public endpoints with credentials. This section is what you add once it works, and it is genuinely optional — but one item on it is not defence in general, it is a documented limitation of this topology turning into a solved problem.

The rate budgets stop multiplying. High availability lists it as a cost of scaling out: every instance enforces its own per-client rate budget, so two instances hand an attacker twice the budget on the approval route, and N instances N times. A Front Door WAF rate limit sits before the load balancer, which makes it the one place in this architecture where a per-client budget is enforced once rather than per instance. Nothing else on this page closes that.

The reason to decline: this section adds four billable resources and a tier upgrade on top of an already-Premium plan and vault. No figures here — Azure pricing dates faster than any document.

Inbound is a choice between two shapes, and only one of them can be true at a time. Either the app stays on the internet behind a Front Door — the rest of this step — or it leaves the internet entirely and is reached over a VPN, which is step 10 and carries consequences that run past the network. The outbound half below is the same work either way. Read both before building either — the second is the stronger answer to the anonymous approval route and the weaker one to everything a WAF does, and which of those matters more is not a question this page can answer for you.

Front Door in front, private endpoints behind

Inbound — Front Door in front of the app

Front Door Premium with a Private Link origin is the recommendation, because App Service automatically disables its public internet endpoint when the origin is reached over Private Link — which on this product buys something specific, two paragraphs below.

The cheaper alternative is Front Door Standard plus an App Service access restriction:

az webapp config access-restriction add --name bulksigner --resource-group bulksigner-rg --rule-name frontdoor --priority 100 --service-tag AzureFrontDoor.Backend --http-header x-azure-fdid=<front-door-id>

Both halves of that rule are required and the header is the load-bearing one. Microsoft is explicit that IP filtering alone is not sufficient, because other Azure customers' Front Door instances use the same address ranges — the AzureFrontDoor.Backend service tag proves the traffic came through a Front Door, and only X-Azure-FDID proves it came through yours. Never hard-code the address ranges instead of using the tag; that space changes regularly.

This is what finally makes step 4's TrustAnyProxy=true true rather than merely intended. That setting is justified there by App Service's front end having no stable address to list, with a warning that on a reverse-proxy deployment it lets anyone who can reach Kestrel directly name themselves any client address. On this product that address is not cosmetic: it is recorded on every approval as one of the compensating controls for the anonymous approval route, and it is what the rate limiter counts against. Front Door without origin locking would widen that hazard while looking like protection. With Private Link the hazard closes completely, because there is no public endpoint left to reach; with the Standard rule it closes only as far as that access restriction holds, which is a weaker claim and worth holding as one.

Keep TrustAnyProxy=true under both tiers. Do not move to Hosting__ForwardedHeaders__KnownNetworks — it takes literal CIDRs, Front Door's ranges change, and a stale list fails by silently discarding the real client address rather than by refusing anything. The product refuses TrustAnyProxy and a named list together, so this is a real either/or and the wide-plus-locked-origin answer is the correct one here.

Three settings interactions to get right:

  • Leave Front Door's own session affinity on. The dashboard is a Blazor Server circuit and needs pinning end to end; Front Door's affinity is what keeps a client on the origin whose ARR cookie it is holding. The two layers cooperate — Front Door picks the origin, ARR picks the instance.
  • Do not point Front Door's health probe at /api/ready. There is exactly one origin here, so a probe failure has nowhere to fail over to and simply withdraws the whole application. App Service's own Health check is already watching that endpoint and can act on it, by rerouting between instances. Give Front Door a cheap path or leave its probe at the default.
  • Custom domain and managed TLS move to Front Door. App Service httpsOnly=true stays exactly as step 5 set it, and Hosting:RequireHttps stays unset for the reason step 5 gives.
What this does not buy: multi-region

Front Door here is a WAF, a TLS terminator and a DDoS front, not a global load balancer for this application. A second region would be a second set of instances over one work share, and the share's marker gate refuses that by design — see High availability.

Outbound — VNet integration and private endpoints

Integrate the web app into a subnet and give all four data-plane services a private endpoint: Azure SQL, the Azure Files share, the vault and the certificate blob. All four, not three — a private endpoint on most of them leaves the remaining one as the reason the virtual network exists, and nobody notices until an audit does.

Two exclusions, both deliberate:

  • The log table stays public. It is the destination that has to keep working when everything else is broken; a network path that can fail is exactly what a log sink must not depend on, since a sink that cannot report its own failure is the outage that hides itself.
  • Application Insights stays public. Private ingestion requires an Azure Monitor Private Link Scope, a separate construct with its own DNS consequences across every resource sharing it — a larger decision than this section, and one that should be made for a whole subscription rather than for one web app.

10. No public endpoint at all (optional)

The two inbound options are exclusive, and this is the second one: if you built step 9's Front Door, you are not building this. Rather than putting a Front Door in front of a public app, give the app a private endpoint and then take its public endpoint away, so that the only route to it is from your own network: a site-to-site or point-to-site VPN, or ExpressRoute private peering. Outbound is shared by both shapes and applies here unchanged — it is the inbound half that step 9's picture describes and this step replaces. The rate-budget argument in step 9's opening is Front Door's alone; this shape answers it differently rather than not at all, and What this shape does not close below is where.

No public endpoint: the app is reached only from your own network

Choose this one when the requirement is that nobody outside your network reaches this service at all, and Front Door when the service has to be reachable from the internet and the only question is how safely.

The anonymous approval route stops being internet-reachable, and that is the largest thing on this page. /approve/{jobId} and POST /api/approvals/{id} take no credential by design — a decision compensated rather than hidden, with a recorded identification method, the approver's address and user agent, their own rate-limit budget and deliberately coarse refusals (Approvals). Every one of those controls was designed for a page anybody could load. Take the public endpoint away and the population that can reach the route at all is the population already admitted to your network, which is a stronger compensation than anything the product itself can ship. If that unauthenticated route is why you are reading this section, this shape is the answer to it and Front Door is not.

Stated the other way round, that is also the cost: an approver who is not on the network cannot approve. The portal link is a derived HMAC that works from any device, and this shape makes reachable the binding constraint instead. Count the approver population before committing — a director approving a payroll from a phone, an external auditor, anybody at a client — because for each of them the answer becomes a VPN client on that device, and that is much cheaper to know now than after the first payroll parks over a weekend.

What this shape does not close. There is no WAF here, so the ×N rate budget stays ×N; what changes is who can spend it, not the arithmetic. /api/metrics still lands on an arbitrary instance, and upgrades are still stop-the-world. If you need a WAF and no public exposure, the answer is an internal Application Gateway rather than Front Door — which always has a public front end of its own — and that is a third deployment shape, not documented here.

Keep Hosting__ForwardedHeaders__* exactly as step 4 set it, and the address it records gets better. Private Link forwards the real client address to the app, so what lands on an approval record is the approver's own address rather than a proxy hop. TrustAnyProxy=true also stops carrying the caveat step 4 attaches to it, for the same reason the Private Link origin option does: with public access disabled there is no path to Kestrel from outside the network left to abuse.

Provisioning the private endpoint

The private endpoint needs a subnet of its own — not the one VNet integration uses, which is a platform restriction rather than a preference and the most common way this gets built twice:

az network private-endpoint create --resource-group bulksigner-rg --name bulksigner-pe --vnet-name bulksigner-vnet --subnet inbound --group-id sites --connection-name bulksigner-sites --private-connection-resource-id $(az webapp show --name bulksigner --resource-group bulksigner-rg --query id -o tsv)
az network private-dns zone create --resource-group bulksigner-rg --name privatelink.azurewebsites.net
az network private-dns link vnet create --resource-group bulksigner-rg --zone-name privatelink.azurewebsites.net --name bulksigner-vnet-link --virtual-network bulksigner-vnet --registration-enabled false
az network private-endpoint dns-zone-group create --resource-group bulksigner-rg --endpoint-name bulksigner-pe --name default --private-dns-zone privatelink.azurewebsites.net --zone-name azurewebsites
az resource update --ids $(az webapp show --name bulksigner --resource-group bulksigner-rg --query id -o tsv) --set properties.publicNetworkAccess=Disabled

That last command is the one that does the work, and the private endpoint does not imply it. A private endpoint and public access coexist on an App Service by default: build the endpoint, connect over the VPN, watch it work — and the app is still on the internet exactly as it was. Note also that the app's access-restriction rules are not evaluated for traffic arriving through the private endpoint, so the x-azure-fdid rule from the Front Door option is inert here. This shape is enforced by there being no public endpoint, never by a rule.

DNS is where this fails, and it fails as a 403 rather than a timeout

The app's public name keeps resolving after the endpoint exists — to a public address that now refuses you. A client that has not been given private resolution therefore gets a 403 from the platform, which reads like a permissions problem and is not one. Two A records are needed in privatelink.azurewebsites.net, one for the app and one for its scm name; the DNS zone group above creates both, which is why it is worth using rather than hand-writing records. VPN clients resolve through your own DNS, so the last mile is a conditional forwarder from your resolvers into Azure — an Azure DNS Private Resolver inbound endpoint is the maintained way to do that. Keep using the default *.azurewebsites.net host name: the platform certificate is issued for it, and a custom domain here means bringing your own.

Two things you were told to do earlier move inside the network. Step 6 reads the boot narration with az webapp log tail, which reaches the app's SCM site — private now, so run it from a VPN-connected workstation, or read the boot from the table log sink instead. And if you put a private endpoint on the container registry too, the image pull has to be routed through the network explicitly, by setting properties.vnetImagePullEnabled the same way step 2 sets acrUseManagedIdentityCreds; a registry left public needs nothing. What does not change is anything the platform does to itself — Health check still pings /api/ready and ARR affinity still pins the Blazor circuit, both internal to App Service and both indifferent to all of this.

"No internet exposure" here means inbound, and two things still go out

The app keeps its outbound path to the internet, and the two deliberate exclusions in Outbound above depend on it: the Azure table log sink and Application Insights. If your requirement covers egress as well, those two are the whole conversation — Application Insights needs an Azure Monitor Private Link Scope, and the log table needs a private endpoint on its storage account, which collides with the reason that exclusion exists: a log sink is the one destination that has to keep working when the network is what broke. Read that argument before closing it, and note that simply turning the sink off is not the cheap way out — in cluster mode it logs a Critical at startup, because a container's rolled log files go away with the container (Before you start, item 5).


When something refuses

Every cluster failure mode, with its exact message and its fix, is in Troubleshooting. The five you are most likely to meet on a first deployment:

SymptomCause
Cluster mode refused to start, naming keysOne of the boot refusals in Before you start. The message names every failing key at once rather than one per attempt.
The container never startsTwo very different causes share this symptom, and step 2's is the one you will think of first. Either the image pull failed (acrUseManagedIdentityCreds never set — step 2), or a profile could not resolve its certificate, which is fatal by design: an unreachable vault, an expired client secret, or a .cer that does not pair with KeyName (step 3). The log stream tells them apart — a pull failure leaves it empty because there is no application yet, while a certificate failure writes the reason before exiting. Read it before assuming the registry.
Boot refused naming an instance identity already beatingTwo hosts presenting one name, or a second deployment pointed at this database — most often a slot carrying production's connection string. If the holder is genuinely gone its row goes stale on its own; waiting out Cluster:StaleAfterSeconds is the supported fix.
Boot refused naming two operational storesThe work share's marker says it belongs to a different store. Two clusters over one work share is mutual data destruction that no database can see, which is what that gate exists to catch.
Operators bounced to sign-in intermittentlyThe instances are not sharing one key ring — usually one host whose Cluster:Enabled is false, or instances pointed at different stores.

Related: High availability and its limits · Installation · Certificates · Configuration · Operations · Troubleshooting