Pular para o conteúdo principal

Configuration

Every appsettings.json key for Lacuna Bulk Signer — type, default, environment-variable override, and whether it is required.

Configuration sources, in precedence order​

Later sources override earlier ones:

  1. appsettings.json (built-in defaults)
  2. appsettings.{Environment}.json (e.g. appsettings.Production.json)
  3. appsettings.json + appsettings.{Environment}.json found under BULK_SIGNER_CONFIG_DIR
  4. Environment variables (Section__Sub__Key)

The BULK_SIGNER_CONFIG_DIR step is what lets the binary live in a read-only install location (/opt/bulksigner, %ProgramFiles%\Lacuna\BulkSigner) while the operator-edited production config lives elsewhere (/etc/bulksigner, %ProgramData%\Lacuna\BulkSigner\config). The install scripts set this variable; if you change install paths, update the variable in lockstep.

Environment-variable mapping follows the ASP.NET Core rule: a JSON key like Signing:Certificate:Pfx:Password maps to Signing__Certificate__Pfx__Password (double underscore is the separator).

Changed in 2.3.1 — the shipped appsettings.json is production-neutral

The appsettings.json inside the binaries and the image declares no signing profiles, no Signer block and no secret of any kind — not even a placeholder Auth:ApiKey. It matters because it is read under every environment name, and configuration can override a key but never remove one: before 2.3.1, sample profiles in that file merged underneath profiles declared as environment variables. The one consequence at the upgrade: a deployment that never set Auth:ApiKey itself was running on the old placeholder and now refuses to start, naming the key. Set Auth__ApiKey as every install path describes.

Markers used in the tables​

MarkerMeaning
REQUIREDThe service refuses to start (or signing refuses to run) without a non-empty value.
SECRETSensitive — prefer the environment-variable override over a value committed to a file.

Logging / Logging:File​

Standard Microsoft.Extensions.Logging knobs (Logging:LogLevel:*) work as usual; the Logging:File block configures the file sink.

KeyTypeDefaultEnv overrideNotes
Logging:LogLevel:DefaultstringInformationLogging__LogLevel__DefaultStandard logging level.
Logging:LogLevel:Microsoft.AspNetCorestringWarningLogging__LogLevel__Microsoft.AspNetCoreLowers framework chatter. The same Warning floor is applied in code to Microsoft.AspNetCore and to Entity Framework Core's database-command category, so neither the request pipeline nor every executed SQL statement reaches the log at Information. Failed commands still log.
Logging:File:Pathstringdata/logs/bulksigner-.logLogging__File__PathREQUIRED. File-sink path template. The trailing - before .log plus daily rolling produces bulksigner-yyyyMMdd.log.
Logging:File:RollingIntervalstringDayLogging__File__RollingIntervalOne of Day, Hour, Minute, Infinite.
Logging:File:FileSizeLimitByteslong50000000Logging__File__FileSizeLimitBytesPer-file cap; the sink rolls to …_001.log past this. Bounds: 64 KB to 10 GB.
Logging:File:RetainedFileCountLimitint14Logging__File__RetainedFileCountLimitOlder files are deleted as rotation advances. Bounds: 1–365.
Logging:File:MinimumLevelstringInformationLogging__File__MinimumLevelOne of Verbose, Debug, Information, Warning, Error, Fatal.
Logging:File:WriteToConsolebooltrueLogging__File__WriteToConsoleWhen true, also writes to stdout. The same redacting formatter runs on both sinks.

Logging:AzureTable — a second log sink​

A deployment on an ephemeral filesystem — a container that is replaced rather than restarted — loses data/logs/ every time. This block sends log events to an Azure Storage table as well, so the diagnostic stream survives the host. It is the last of three places state can move into Azure: files can already live on an Azure Files share (Storage:Provider) and the operational record in Azure SQL (Database).

KeyTypeDefaultEnv overrideNotes
Logging:AzureTable:EnabledboolfalseLogging__AzureTable__EnabledOff unless set. true over an incomplete block is a boot refusal naming the missing key, not a silently inert sink.
Logging:AzureTable:TableNamestringbulksignerlogsLogging__AzureTable__TableNameMust already exist — the service does not create it. Alphanumeric only, 3–63 characters, must not start with a digit; a bad name is refused at boot rather than on the first write.
Logging:AzureTable:MinimumLevelstring(inherits Logging:File:MinimumLevel)Logging__AzureTable__MinimumLevelNarrow the table without narrowing the file. Setting it below the global minimum does nothing — that gate runs before any sink.
Logging:AzureTable:ServiceUristring—Logging__AzureTable__ServiceUriREQUIRED when enabled. The table endpoint, e.g. https://contosologs.table.core.windows.net. A URL carrying a query string is refused — that is how a shared-access signature arrives, and SAS is deliberately not among this product's storage credentials. Refusing it also keeps this value non-secret, which is what makes it safe to print on the startup banner.
Logging:AzureTable:Credentialstring—Logging__AzureTable__CredentialREQUIRED when enabled. ManagedIdentity, ServicePrincipal or AccountKey. Never defaulted — see below.
Logging:AzureTable:AccountNamestring—Logging__AzureTable__AccountNameRequired for AccountKey and that mode only: the shared-key credential needs the account by name, and it cannot be read off ServiceUri without guessing whether that URL is a production endpoint or an emulator one.
Logging:AzureTable:AccountKeystring—Logging__AzureTable__AccountKeySECRET. AccountKey mode only.
Logging:AzureTable:TenantIdstring—Logging__AzureTable__TenantIdServicePrincipal mode only.
Logging:AzureTable:AppIdstring—Logging__AzureTable__AppIdServicePrincipal mode only.
Logging:AzureTable:AppSecretstring—Logging__AzureTable__AppSecretSECRET. ServicePrincipal mode only. Env override recommended.
Logging:AzureTable:QueueLimitint10000Logging__AzureTable__QueueLimitEvents held in memory while the table is unreachable. Beyond it events are dropped and counted — reported on /api/ready and on bulksigner_log_sink_dropped_total. The bound exists because an unbounded buffer against a long outage is an out-of-memory kill of a signing service because logging broke.
Logging:AzureTable:BatchSizeLimitint100Logging__AzureTable__BatchSizeLimitEvents per write. Capped at 100, the documented ceiling on an entity-group transaction; above it a batch stops being one transaction rather than merely being slower.
Logging:AzureTable:BatchPeriodSecondsint5Logging__AzureTable__BatchPeriodSecondsHow long a partial batch waits before being written.

The credential keys are the same ones Storage:AzureFiles and a profile's signing material blob use, so an operator who has configured one storage credential in this product has configured them all. The identity needs Storage Table Data Contributor on the table or its account.

A block naming no credential is refused at boot. Falling back to a development identity would mean a missing or unassigned managed identity working on a laptop and failing only in production.

Prefer ManagedIdentity to AccountKey. Microsoft recommends disallowing Shared Key authorization, and many organisations set allowSharedKeyAccess = false at the account — on such an account AccountKey cannot be configured at all. An account key also grants full data-plane access to the whole account, so a key used for a log table has also handed out access to any Azure Files share the same account holds.

Two rules that are enforced, not advisory​

The table may never be the only sink. Logging:File:Enabled is the off switch (for a read-only root filesystem), but a configuration where both local sinks are off is refused at boot: if the table stops accepting writes, the failure to log is itself unloggable. Keep the file sink, or keep WriteToConsole — in a container that is stdout, which docker logs and App Service log streaming already capture.

Nothing prunes the table

No TTL, no lifecycle rule, no bulk delete — the table grows until you delete from it. Read Retention before enabling this, and schedule the pruning script. If two deployments share a storage account, give each its own table.

Database and ConnectionStrings​

The operational store — jobs, their history, operational events, the pipeline's pause flag, the frozen approval rules and the recorded approvals — lives in SQLite by default. It can instead live in your own SQL Server 2022+ or Azure SQL Database.

KeyTypeDefaultEnv overrideNotes
Database:ProviderenumSqliteDatabase__ProviderSqlite or SqlServer. Case-insensitive; absent means Sqlite, so an existing deployment configures nothing. An unrecognised value is refused at boot, naming the key, your value, and the valid names.
ConnectionStrings:DefaultstringData Source=data/db/bulksigner.dbConnectionStrings__DefaultREQUIRED under SqlServer — and SECRET there, because it is the whole of the credential (SQL login, Entra ID, managed identity, Windows integrated; there is no separate credential key). Under Sqlite it may be omitted — the default above is real. Point a SQLite path under Storage:Root so one mount covers both the data tree and the DB.

There is deliberately no Database:Credential discriminator: SQL Server has expressed authentication in the connection string for thirty years, and adding a second mechanism beside one that already works would only add a way for the two to disagree.

The store's provider is independent of Storage:Provider — files on an Azure Files share with the store in SQLite, or the reverse, are both ordinary. Neither combination makes running more than one instance supported on its own: that is Cluster:Enabled, which requires SqlServer and AzureFiles but is not implied by them. Off that switch, see Operations.

Azure SQL Managed Instance and SQL Server on a VM are configure as SqlServer, untested — the same implementation reaches them and nothing about them is known to differ, but neither is exercised.

Three boot refusals sit on ConnectionStrings:Default, and which apply depends on the provider:

  • Under Sqlite, a connection string naming an Azure Files location — a database file reached over SMB is the documented way to corrupt one.
  • Under SqlServer, the reverse: a data source naming a file rather than a server, which is what a deployment that flipped the provider and left the SQLite path behind produces.
  • Under SqlServer, an absent string. No server is guessed at, where under Sqlite a real default file path is.

No refusal ever echoes the connection string, because it may carry a password; only the data source is quoted.

Before you point it at SQL Server​

  1. The database has to exist already. Bulk Signer creates its tables, not its database. The boot probe opens a connection to the database the connection string names, so an absent one reads as an unreachable store and the migration is skipped.
  2. A login with the rights below, mapped to a user in that database.
  3. Encryption the client will accept. Encrypt defaults to True in the SQL client, so an on-premises server whose TLS certificate the host does not trust fails the login with a certificate chain … not trusted error. Install a trusted certificate on the server (the correct fix) or, knowingly and only where a man-in-the-middle is not a concern, add TrustServerCertificate=True. Azure SQL needs neither.

Least privilege. The service reads and writes its own tables and applies migrations at boot. That is db_datareader + db_datawriter + db_ddladmin — not db_owner:

-- Once, by a DBA, in the database Bulk Signer will use.
CREATE USER [bulksigner] FOR LOGIN [bulksigner]; -- a SQL login
-- On Azure SQL with a managed identity or service principal, instead:
-- CREATE USER [<identity-or-app-name>] FROM EXTERNAL PROVIDER;

ALTER ROLE db_datareader ADD MEMBER [bulksigner];
ALTER ROLE db_datawriter ADD MEMBER [bulksigner];
ALTER ROLE db_ddladmin ADD MEMBER [bulksigner]; -- for the boot that applies a migration

That block assumes the database and login already exist and that you are connected to that database. Creating them differs by engine:

-- SQL Server: from master, then switch.
CREATE DATABASE [BulkSigner];
GO
ALTER DATABASE [BulkSigner] SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
GO
CREATE LOGIN [bulksigner] WITH PASSWORD = '<a strong password>';
-- or, for Windows integrated auth: CREATE LOGIN [DOMAIN\HOSTNAME$] FROM WINDOWS;
GO
USE [BulkSigner];
GO
-- …then the CREATE USER + ALTER ROLE block above.
-- Azure SQL: TWO connections, because USE cannot switch databases there and
-- CREATE DATABASE must run from master on its own.
-- Connection 1, to master:
CREATE DATABASE [BulkSigner];
GO
-- Connection 2, to BulkSigner itself: the CREATE USER + ALTER ROLE block above.

READ_COMMITTED_SNAPSHOT is already on in Azure SQL. Without it, the dashboard's reads take shared locks and block behind the pipeline's writes — which arrives as "the dashboard hangs while a batch signs" rather than as a database setting. Bulk Signer reports it and never issues the statement that changes it: that needs exclusive access to a database that is yours.

db_ddladmin is what creates the tables and indexes, so it is needed on the first boot and on any boot after an upgrade that ships a migration. Leaving all three roles in place is the simpler and safer default; the migration runs at every boot and is a no-op when there is nothing to apply.

The two credential shapes​

The connection string carries it either way. Prefer the passwordless shape wherever the host can authenticate as itself — there is then no secret to rotate, to leak into a log, or to find in a backup.

# Passwordless — Azure SQL, from a host with a system-assigned managed identity
Server=tcp:sqlsrv01.database.windows.net,1433;Initial Catalog=BulkSigner;Authentication=Active Directory Managed Identity;Encrypt=True;

# Passwordless — on-premises, from a Windows service account (integrated auth)
Server=sqlsrv01;Initial Catalog=BulkSigner;Integrated Security=True;Encrypt=True;

# With a secret — a SQL login
Server=sqlsrv01;Initial Catalog=BulkSigner;User ID=bulksigner;Password=<secret>;Encrypt=True;

# With a secret — an Entra service principal
Server=tcp:sqlsrv01.database.windows.net,1433;Initial Catalog=BulkSigner;Authentication=Active Directory Service Principal;User ID=<app-id>;Password=<client-secret>;Encrypt=True;

A user-assigned identity is reached by adding its client id as User Id=<client-id> — unlike Storage:AzureFiles, whose ManagedIdentity mode is system-assigned only, because the SQL client acquires the token itself.

Under the Windows Service target the service runs as the virtual account NT SERVICE\LacunaBulkSigner, which reaches the network as the computer account — so the login to create on SQL Server is DOMAIN\HOSTNAME$, not the virtual account's own name.

The environment variable replaces the whole value — it does not merge with the JSON

ConnectionStrings:Default is a single configuration key, so there is no way to keep the server in appsettings.Production.json and supply only the password from the environment. Either the JSON holds the complete string (fine when it is passwordless) or the environment does. A JSON value left in place alongside the environment variable is silently ignored.

Switching provider starts with an empty store​

There is no importer and no boot-time check for a SQLite file left behind. A deployment that sets Database:Provider = SqlServer comes up against an empty schema: no jobs, no history, no operational events — and no approval snapshots and no recorded approvals, which are the two things the product otherwise retains for ever precisely because they are evidence of who authorised a payment file.

danger

Archive the old db/bulksigner.db deliberately, before the switch, and keep it as long as your retention policy requires the evidence in it. Copy it while the service is stopped, and keep a SQLite client to hand. The reverse switch has the same property. See Installation.

What the boot tells you about the store​

Every deployment gets one operational store row on the ready-summary banner naming the provider, and under SqlServer the server and the database with it — never the connection string. A SqlServer deployment gets two more rows:

  • store status, from one probe at boot. An unreachable store is reported and does not stop the host (a database down during a maintenance window must not turn a restart into an outage); the migration is skipped, /api/ready stays red until it answers, and the next boot that finds it applies the schema.
  • store isolation, plus an ops-console warning, when READ_COMMITTED_SNAPSHOT is off. When it is on, nothing is reported.

Engine-specific behaviour you do not configure​

  • Under Sqlite, every connection gets journal_mode=WAL, synchronous=NORMAL and busy_timeout=30000. WAL keeps the pipeline's per-job status writes from serializing on SQLite's single-writer fsync (the throughput ceiling at higher Pipeline:MaxConcurrency).
  • Under SqlServer, transient-fault retry is on and has no knob — the initial attempt plus up to six retries against the error numbers the SQL client classifies as transient, each delay growing exponentially and capped at 30 seconds. It is on because running against Azure SQL effectively requires it. There is no configuration key, deliberately: a retry budget an operator can tune is a retry budget that gets tuned to zero during an incident.

Signing​

Validation fails fast at startup if any required key is missing or invalid.

KeyTypeDefaultEnv overrideNotes
Signing:PkiSdkLicensestring""Signing__PkiSdkLicenseREQUIRED, SECRET. Lacuna PKI SDK license string (base64). Environment-variable form preferred.
Signing:ProfileSecretsKeystring""Signing__ProfileSecretsKeySECRET. The key every secret held by a stored signing profile is encrypted under. Leave it unset unless the deployment's profiles carry a secret — see below.
Signing:TrustLacunaTestRootboolfalseSigning__TrustLacunaTestRootAlso trust the Lacuna test PKI root — the issuer of the Turing / Fermat test certificates — for a homologation environment. Refused at boot when ASPNETCORE_ENVIRONMENT is Production. Not a secret. See below.
Signing:Certificate:SourceenumPfxSigning__Certificate__SourceREQUIRED. One of Pfx, Pkcs11, WindowsStore, AzureKeyVault. Only the matching subtree below is consulted.

Signing:ProfileSecretsKey — what stored profile secrets are encrypted under​

Signing profiles live in the operational store (see Signing:Profiles[]), so this key is required by any deployment whose profiles carry a secret — a PKCS#12 password, an Azure Key Vault application secret, a signing material blob credential, or a PKCS#12 file uploaded through the dashboard. Each is encrypted under it, and the store never holds any of them in the clear.

Set it before the first boot that imports profiles, and before anyone creates a profile carrying a secret from the dashboard. The import refuses rather than writing a secret it cannot protect, naming this key, its environment variable and the profiles that carry a secret; the dashboard's create and edit forms refuse the same way. A deployment whose profiles carry no secret at all — a passwordless PFX, a PKCS#11 token whose PIN is an environment variable, a Windows store certificate, a profile whose approvers sign — needs no key and is never asked for one.

It is deliberately not in the database: a key stored beside its ciphertext protects against nothing that matters. That is also why the session key ring is not reused for it — under Cluster:Enabled that ring is itself rows in the same store (see The session key ring).

Three cases, decided differently on purpose:

  • Protected profile data in the store and no key to read it with stops the boot. The fix is an environment variable, not a row in a database you would need the service running to reach.
  • An import or a save that would create protected data with no key is refused at that moment, so a store never ends up holding a value nothing can open.
  • A key that is set but wrong — rotated, restored from elsewhere, mistyped — is not a refusal. The service starts, the profiles it cannot read come up degraded (named on the startup banner and on /api/ready), and every profile without a secret keeps signing. Setting an environment variable fixes a missing key; it fixes nothing about a rotated one, so refusing would be permanent.
Losing this key means re-entering every affected profile's certificate secret

There is no escrow and no recovery path, and rotating it has the same effect as losing it. Back it up wherever the encryption password, ApproverPortal:LinkSecret and ApproverSecondFactor:SeedSecret are backed up. The recovery, if it happens, is to re-enter each named profile's password or credential from its page on the dashboard and restart.

The PKCS#11 PIN is not one of the values this protects and never becomes one: it stays read from the environment variable named by Pkcs11:PinEnvVar, and a PIN written anywhere it could be read back is refused at boot.

Signing:TrustLacunaTestRoot — test certificates for a homologation​

The service holds every signature to the ICP-Brasil roots alone — the profile key when it signs, the verifier afterwards, and an approver's certificate before the token is asked for a PIN — with nothing from the operating system's store. Lacuna's public test PKI (the Turing / Fermat test certificates) is not under them, so by default a job signed with one fails and an approver presenting one is refused as approval.certificate-invalid.

Signing:TrustLacunaTestRoot = true widens the trust set to ICP-Brasil, the PKI SDK's Windows trust set (the machine store on Windows) and the Lacuna test root, for a homologation environment that wants to run the released product with the test certificates instead of buying a real e-CPF for every approver. Three things to know:

  • It is refused under the environment name Production. The boot fails naming the key, the environment and the remedy: a homologation host says ASPNETCORE_ENVIRONMENT=Staging (or any name other than Production). Azure App Service defaults the name to Production when nothing sets it, so there the two settings go together.
  • It is one trust set for the whole host. Every profile's key, every verification and every approver's certificate are held to the same roots; there is no per-profile form. The startup banner's trust set row names the Lacuna test root when it is on, and the console and the durable log carry a warning at every boot.
  • It is not a secret. A root certificate is public material and the key is a boolean.

Leave it unset on every deployment that signs anything real.

Signing:Certificate:Pfx — when Source = Pfx​

KeyTypeDefaultEnv overrideNotes
Signing:Certificate:Pfx:Pathstring""Signing__Certificate__Pfx__PathREQUIRED unless Blob is set — exactly one of the two. Absolute path to the .pfx/.p12 file.
Signing:Certificate:Pfx:Passwordstring""Signing__Certificate__Pfx__PasswordSECRET. Empty string is allowed for passwordless test fixtures. Prefer the env-var form.

Signing:Certificate:Pkcs11 — when Source = Pkcs11​

KeyTypeDefaultEnv overrideNotes
Signing:Certificate:Pkcs11:ModulePathstring""Signing__Certificate__Pkcs11__ModulePathREQUIRED. Absolute path to the vendor PKCS#11 driver (.so/.dll/.dylib).
Signing:Certificate:Pkcs11:Thumbprintstring""Signing__Certificate__Pkcs11__ThumbprintREQUIRED. SHA-1 thumbprint (hex, no spaces) of the signing cert on the token. Required even when the token holds a single identity.
Signing:Certificate:Pkcs11:PinEnvVarstringBULK_SIGNER_PKCS11_PINSigning__Certificate__Pkcs11__PinEnvVarName of the env var that supplies the PIN. The validator refuses to start if a literal Pin key appears under Pkcs11.

Signing:Certificate:WindowsStore — when Source = WindowsStore​

Windows-only. The validator refuses this source on non-Windows hosts at startup.

KeyTypeDefaultEnv overrideNotes
Signing:Certificate:WindowsStore:StoreLocationstringCurrentUserSigning__Certificate__WindowsStore__StoreLocationCurrentUser or LocalMachine. Use LocalMachine when the cert was imported machine-wide; the service account does not see the operator's CurrentUser store.
Signing:Certificate:WindowsStore:StoreNamestringMySigning__Certificate__WindowsStore__StoreNameLogical store name. My is the personal store.
Signing:Certificate:WindowsStore:Thumbprintstring""Signing__Certificate__WindowsStore__ThumbprintREQUIRED. SHA-1 thumbprint (hex, no spaces).

Signing:Certificate:AzureKeyVault — when Source = AzureKeyVault​

The private key stays in the vault and each signature is a remote sign call; the matching public certificate is supplied separately as a .cer. Endpoint, AppId, AppSecret and KeyName are always required, plus exactly one of CerPath (a file on this host) or Blob (an object in Azure Blob Storage). Startup fails naming each missing one.

KeyTypeDefaultEnv overrideNotes
Signing:Certificate:AzureKeyVault:Endpointstring""Signing__Certificate__AzureKeyVault__EndpointREQUIRED. Vault URL. Must be an absolute https:// URL — a bare DNS name is refused at startup.
Signing:Certificate:AzureKeyVault:AppIdstring""Signing__Certificate__AzureKeyVault__AppIdREQUIRED. Application (client) ID of the Microsoft Entra ID app registration.
Signing:Certificate:AzureKeyVault:AppSecretstring""Signing__Certificate__AzureKeyVault__AppSecretREQUIRED, SECRET. Entra ID client secret. Unlike the PKCS#11 PIN this is permitted in a config file, but the env-var form is recommended.
Signing:Certificate:AzureKeyVault:KeyNamestring""Signing__Certificate__AzureKeyVault__KeyNameREQUIRED. Name of the key object in the vault that performs the signature. A vault certificate object is not accepted.
Signing:Certificate:AzureKeyVault:CerPathstring""Signing__Certificate__AzureKeyVault__CerPathREQUIRED unless Blob is set — exactly one of the two. Path to the .cer holding the public certificate for KeyName. Boot fails if its public key does not match the vault key.

…:Blob — reading the file from Azure Blob Storage​

A host with no durable local disk — a container, an App Service, an AKS pod — has nowhere to keep a .pfx or a .cer. The two sources that name a file can instead name a blob.

Available on Pfx (holding the .pfx, instead of Path) and on AzureKeyVault (holding the .cer, instead of CerPath), in the legacy block and in every Signing:Profiles[] entry. Exactly one of the local path or this block; both set, or neither, is refused at boot. Nothing here inherits from the AzureKeyVault credential beside it or from Storage:AzureFiles.

Replace <SRC> below with Pfx or AzureKeyVault.

KeyTypeDefaultEnv overrideNotes
Signing:Certificate:<SRC>:Blob:Urlstring(unset)Signing__Certificate__<SRC>__Blob__UrlREQUIRED when the block is present. Full blob URL, e.g. https://contoso.blob.core.windows.net/certificates/signer.cer. Must be absolute https://, must name a container and a blob, and must not carry a query string — that would be a shared-access signature, which is not an accepted credential. Because of that rule the URL is never secret and is printed on the startup banner. Any host is accepted, so sovereign clouds need no extra key.
Signing:Certificate:<SRC>:Blob:Credentialenum(unset)Signing__Certificate__<SRC>__Blob__CredentialREQUIRED when the block is present. One of ManagedIdentity, ServicePrincipal, AccountKey. Never defaulted — silently using the host's own Azure identity is not a decision made on your behalf.
Signing:Certificate:<SRC>:Blob:TenantIdstring(unset)Signing__Certificate__<SRC>__Blob__TenantIdREQUIRED for ServicePrincipal. Required even when AppId matches the AzureKeyVault block's — that block has no tenant key, and nothing inherits.
Signing:Certificate:<SRC>:Blob:AppIdstring(unset)Signing__Certificate__<SRC>__Blob__AppIdREQUIRED for ServicePrincipal. Needs Storage Blob Data Reader on the container.
Signing:Certificate:<SRC>:Blob:AppSecretstring(unset)Signing__Certificate__<SRC>__Blob__AppSecretREQUIRED for ServicePrincipal, SECRET. Env-var form recommended.
Signing:Certificate:<SRC>:Blob:AccountKeystring(unset)Signing__Certificate__<SRC>__Blob__AccountKeyREQUIRED for AccountKey, SECRET. Grants full data-plane access to the whole account and cannot be scoped down; warned about at startup.
Under Pfx, the blob is the signing key

An account key grants full data-plane access to the entire storage account, cannot be scoped down, and does not expire. Under AzureKeyVault the blob holds a .cer — public material. Under Pfx it holds a PKCS#12 file, so a leaked account key is your signing key. Prefer ManagedIdentity, or ServicePrincipal where the host can reach a tenant.

The file is read once, at boot — a renewed blob needs a restart, exactly as a renewed local file does. The shape rules above (both or neither, a missing Url or Credential, a query string) are refused at boot, but an unreachable or unreadable blob leaves that profile degraded and the host running: it is named on the startup banner, in the durable log and on /api/ready, jobs routed to it fail with profile.degraded, and every other profile keeps signing. See Certificates.

Changed in 2.1.0 — a certificate that will not open no longer stops the host

Up to 2.0.x, one profile whose certificate failed to load — a mistyped path, a wrong password, an unreachable blob or vault — refused the whole boot. Profiles are now edited from the dashboard, and a page served by a host that refuses to start is no recovery path, so that profile alone is degraded instead. Fix the certificate and restart: a key is opened once at startup and never reloaded.

See Certificates for thumbprint discovery commands, the Azure setup walkthrough, and a deeper look at each source.

Signing:Profiles[] — per-folder signing profiles​

A signing profile bundles every per-folder decision (format, certificate, verify, encrypt, certificate validation, approval) under a name. A profile chooses the watched folder it feeds from, one folder per profile; Storage:Inputs[].Profile is only the seed's input for that choice (see below).

Changed in 2.1.0 — this section is a one-time seed, not the source of truth

Signing profiles live in the operational store. On the first boot against an empty profile table the keys below are imported as rows; from then on the store is authoritative and this section is ignored. Nothing is merged, and editing it after that first boot has no effect — the startup log says so, naming the section, on every boot that finds it still populated. Keep it if you want a fresh deployment to come up configured; remove it once the profiles are in the store.

From then on a profile is read, created and edited on the dashboard's Signing profiles page — its behaviour (the format and the on/off stances below), its input folder, its approval rule and its certificate — and it is disabled there rather than deleted. The same page is where to look after the first boot to confirm what the seed imported. See Dashboard.

A change to a stored profile takes effect without a restart — except its certificate. Every profile write is noticed by the pipeline on the poll it already runs, so an edited profile governs the next job claimed — format, Verify, Encrypt, CheckCNAB240, CheckCnab240PaymentDates, the input folder and the whole approval rule — within one Pipeline:PollIntervalSeconds on every instance. A certificate change is stored at once but takes a restart: a profile holds an open private-key handle, and swapping a key under a job that is mid-signature is not a risk worth taking for a field that changes about once a year. Until the restart the profile is marked as waiting for one, and the profile list, the banner and each job's recorded source keep naming the certificate actually in force — which is what keeps a job's audit record honest about what signed it.

Two configuration modes are supported, and both describe what gets seeded:

  • Legacy mode (default — Signing:Profiles[] omitted or empty). One profile named default is derived from the existing Signing:Certificate block plus Encryption:Enabled and written as a real row. No config changes are needed for a simple single-certificate install.
  • Profile mode (declare Signing:Profiles[]). Each entry is a named profile with its own certificate and posture. Signing:Certificate is ignored. Every entry validates as if it were the global certificate block — the same Pfx / Pkcs11 / WindowsStore / AzureKeyVault rules apply per profile.

A profile carrying a secret needs Signing:ProfileSecretsKey set before that first boot. A PKCS#12 password, an Azure Key Vault application secret or a signing material blob credential is encrypted at rest, and the import refuses rather than writing one in the clear.

The rules in the table below are enforced wherever a profile changes: at the seed for an entry declared here, and on the dashboard's forms for a profile created or edited there. A profile already in the store is never re-validated at boot, so a rule tightened by an upgrade shows up as a warning or a degraded profile rather than as a host that will not start.

KeyTypeDefaultEnv overrideNotes
Signing:Profiles[].Namestringn/aSigning__Profiles__0__NameREQUIRED. Same regex as folder names: ^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$. Unique across the list. The name is what the seed's Storage:Inputs[].Profile and a REST upload's ?profile= refer to, and it surfaces in metric labels, dashboard chips, and audit messages.
Signing:Profiles[].EnabledbooltrueSigning__Profiles__0__EnabledWhether new files may be routed at this profile. A profile is disabled, never deleted, so historical jobs keep resolving to a real rule and jobs already queued against a disabled one run to completion. Enforced where files enter: a watched folder, an upload, a rescan and a retry are all refused with profile.disabled. Set from the profile's page after the first boot; disabling there is refused while the profile feeds from a watched folder, naming it — clear the folder in the same save, or give it to another profile first. default cannot be disabled at all — Enabled: false on it is a boot refusal, since that is where an upload that names no profile lands.
Signing:Profiles[].Formatenumn/aSigning__Profiles__0__FormatREQUIRED for every profile except default: Pades, Cades or Xades. Only default — the profile an upload that names no profile lands on — may leave it unset, in which case the format is detected per file by extension.
Signing:Profiles[].MethodenumLocalSigning__Profiles__0__MethodLocal (sign with the configured local certificate), LacunaSigner (dispatch to Lacuna Signer for a human participant) or SignerFolder (new in 2.16.0 — sign, with the profile's own certificate, documents a person created in a bound Lacuna Signer folder; the opposite direction). A LacunaSigner or SignerFolder profile is refused on a host with no Signer section. See Lacuna Signer integration for both Signer methods.
Signing:Profiles[].VerifybooltrueSigning__Profiles__0__VerifyWhen false, the worker skips the post-sign verification round-trip. The startup banner emits a warning so the low-trust posture is operator-visible, and turning it off from the dashboard asks for confirmation first.
Signing:Profiles[].EncryptboolfalseSigning__Profiles__0__EncryptWhen true, the worker AES-256-GCM-encrypts the signed output. Requires Encryption:Enabled = true (the validator refuses the broken combination at startup).
Signing:Profiles[].ValidateCertificatebooltrueSigning__Profiles__0__ValidateCertificateWhen false, the worker skips the pre-sign certificate chain / revocation check. The startup banner emits a warning. The default profile derived in legacy mode carries false, preserving the behaviour of installs that predate profiles. Must be false when Method = LacunaSigner — there is no local cert to validate.
Signing:Profiles[].PreserveFileExtensionboolfalseSigning__Profiles__0__PreserveFileExtensionWhen true, the signed output keeps the original file's extension using the PAdES-style .signed infix: CAdES writes remessa.signed.rem instead of remessa.rem.p7m; XAdES writes nota.signed.nfe instead of nota.signed.xml. Only valid when Format = Cades or Xades — PAdES output already preserves .pdf, so the validator refuses the flag there. Use when a downstream system (a bank ingesting signed remessas, say) requires the original extension.
Signing:Profiles[].SaveAsPemboolfalseSigning__Profiles__0__SaveAsPemWhen true, the CAdES signature is written PEM-encoded (-----BEGIN PKCS7----- armor) instead of raw DER, and the output name becomes <name>.pem instead of <name>.p7m. Only valid when Format = Cades. Verification always runs on the DER bytes before the PEM encoding; with Encrypt = true the BSENC envelope wraps the PEM text. May be combined with PreserveFileExtension, in which case the name follows that flag and only the content is PEM.
Signing:Profiles[].CheckCNAB240boolfalseSigning__Profiles__0__CheckCNAB240When true, every file routed through this profile is parsed and validated as a Banco do Brasil CNAB240 remessa before it is signed. A non-compliant file never reaches the signer: the job goes to Failed with ErrorMessage = cnab240.invalid, the staged copy is relocated to the error folder, and the violations are recorded on the job history and as a Cnab240ValidationFailed operational event. Applies to both Local and LacunaSigner. Validation is structural only — see CNAB240 payment files. Key matching is case-insensitive, so CheckCnab240 also binds.
Signing:Profiles[].CheckCnab240PaymentDatesbooltrueSigning__Profiles__0__CheckCnab240PaymentDatesNew in 2.15.0. Read only when CheckCNAB240 is true. When true (the default), a remessa whose earliest Data do Pagamento has passed is refused at the sign call with ErrorMessage = cnab240.payment-date-passed. When false, such a file signs, and the job history and a Cnab240PaymentDateCheckSkipped operational event record that it was stale — for a bank that processes past-dated payments on the next business day. Structural validation is unaffected. Read at the signature, never frozen onto a job, so a change reaches the next signature without a restart, a parked job included. See CNAB240 payment files.
Signing:Profiles[].ApprovalnestedabsentSigning__Profiles__0__Approval__…Optional. Present means jobs on this profile park in AwaitingApproval before any signature exists. Only valid alongside CheckCNAB240 = true — refused at the seed, and refused from either side on the profile's page afterwards (an approval rule cannot be added while the check is off, and the check cannot be turned off while a rule stands). A job that nonetheless reaches the gate without the parse fails as approval.content-unmeasured rather than parking. See below.
Signing:Profiles[].Certificate.*nestedn/aSigning__Profiles__0__Certificate__…REQUIRED when Method = Local, unless the profile is keyless (Approval.Signers = Approvers, below). Same shape as the global Signing:Certificate block. A shape mistake in any entry — a missing key, both a path and a blob — fails startup with an aggregated error; a certificate that is well-formed but will not open leaves that profile degraded and the host running. Refused when Method = LacunaSigner.
Signing:Profiles[].Signer.Namestringn/aSigning__Profiles__0__Signer__NameREQUIRED when Method = LacunaSigner. Display name of the participant Lacuna Signer will send the document to.
Signing:Profiles[].Signer.Emailstringn/aSigning__Profiles__0__Signer__EmailREQUIRED when Method = LacunaSigner. Participant's email address — must contain @.
Signing:Profiles[].Signer.Identifierstringn/aSigning__Profiles__0__Signer__IdentifierREQUIRED when Method = LacunaSigner. Participant's national identifier (CPF in Brazil).
Signing:Profiles[].SignerFolder.Idstring (GUID)n/aSigning__Profiles__0__SignerFolder__IdNew in 2.16.0. REQUIRED when Method = SignerFolder, ignored under every other method. The id of the Lacuna Signer folder whose documents this profile signs — the folder itself, never its subfolders — copied from the folder at Signer. Unique across profiles: two entries naming one folder refuse the boot, naming the first owner. Optional SignerFolder.Name is display only — what the profile page shows for the folder. See Signing in a Lacuna Signer folder below.

Signing:Profiles[].Approval — the approval gate​

Present means jobs on this profile stop before any signature exists and wait for a human. Only valid alongside CheckCNAB240 = true — an approver who cannot be shown the amount is not approving anything meaningful, and the validator refuses the combination at startup. Applies to both Local and LacunaSigner. Full walkthrough: Approvals.

Like the rest of the profile, the block below is seed input: after the first boot the pool, the quorum, the wait budget and the signer set are edited from the profile's page, and the change reaches the next job that parks. A job already parked keeps the rule frozen onto it when it parked, so editing a pool never retroactively authorises anything. Removing somebody from a pool revokes their approver link immediately.

KeyTypeDefaultEnv overrideNotes
…Approval.MinimumApproversint1Signing__Profiles__0__Approval__MinimumApproversThe quorum: how many distinct members of the pool must approve. At least 1 and no larger than the pool — a quorum bigger than the pool can never be reached, so every job would park forever, and the validator refuses it.
…Approval.ExpiresAfterTimeSpanabsentSigning__Profiles__0__Approval__ExpiresAfterOptional wait budget in the d.hh:mm:ss form — "2.00:00:00" is forty-eight hours. Must be positive. A job parked longer is canceled with the reason Approval window expired. and its staged copy moved to error/. Absent (the default) means a parked job waits indefinitely. The window is measured against the budget frozen onto the job at park time. It is a housekeeping timer, not a control on stale payments — the payment-date guard is what refuses a remessa whose dates have passed.
…Approval.SignersstringProfileKeySigning__Profiles__0__Approval__SignersThe signer set: whose signatures the output of a job on this profile carries — ProfileKey (the profile's own certificate, as before), Approvers (each approving person co-signs with a certificate of their own) or ProfileKeyAndApprovers (both). A typo is refused naming the three values. Frozen onto the job with the rest of the rule when it parks. The two sets with approvers in them need a signature means on this host — a WebPki:License for a certificate in the approver's browser, or CloudHub for one held by a cloud provider, either being enough — and a way to identify the approver (ApproverPortal:Enabled, or an Auth:EntraId section); they are refused when either is missing, and also under any Format other than Cades, since an approver's signature is a CAdES co-signature. ProfileKeyAndApprovers is additionally refused alongside Method = LacunaSigner. Under Approvers the profile is keyless: its Certificate block is neither required nor validated, nothing is opened for it at startup, and it is not degraded for lacking one. See Approvals.
…Approval.Approvers[]array[]Signing__Profiles__0__Approval__Approvers__0__…REQUIRED and non-empty when Approval is present. The pool of people permitted to approve — not a list of people who must all approve. With three entries and MinimumApprovers: 1, no individual is required.
…Approval.Approvers[].Namestringn/a…__Approvers__0__NameREQUIRED. Display name. It is what the audit record shows for this approver.
…Approval.Approvers[].Emailstringn/a…__Approvers__0__EmailREQUIRED, must contain @, and must be unique within the pool (case-insensitively). A duplicate would let one human occupy two pool slots and satisfy a quorum of two alone. Masked in console narration and durable logs; stored in full on the job's approval snapshot.
…Approval.Approvers[].Cpfstringn/a…__Approvers__0__CpfREQUIRED. Eleven digits, with or without punctuation (123.456.789-09 and 12345678909 both bind). Check digits are validated at startup — a typo names a different legal person, and the resulting audit row looks exactly as authoritative as a correct one. Display and audit only: nothing branches on it. Redacted from durable logs.
Write ExpiresAfter with its days component

A three-component value is hh:mm:ss only while the first number is 23 or less; at 24 and above .NET reads it as days, so "48:00:00" binds to forty-eight days and satisfies the positive-duration check. It is not refused — a long window may be deliberate — but the startup banner warns at or above 24 days, quoting both the resolved figure (expires=1152h) and the spelling that fixes it. Boot is the only moment this is catchable.

Example: a payment profile that parks for approval​

{
"Name": "pagamentos-bb",
"Format": "Cades",
"Method": "Local",
"CheckCNAB240": true,
"Certificate": {
"Source": "Pfx",
"Pfx": { "Path": "/etc/bulksigner/pagamentos.pfx", "Password": "" }
},
"Approval": {
"MinimumApprovers": 2,
"ExpiresAfter": "2.00:00:00",
"Approvers": [
{ "Name": "Maria Silva", "Email": "maria@empresa.com.br", "Cpf": "12345678909" },
{ "Name": "João Souza", "Email": "joao@empresa.com.br", "Cpf": "111.444.777-35" },
{ "Name": "Ana Ferreira", "Email": "ana@empresa.com.br", "Cpf": "52998224725" }
]
}
}

The seed refuses, before the first job runs — and the profile's page refuses on save afterwards: an Approval block without CheckCNAB240; an empty pool; a MinimumApprovers below 1 or larger than the pool; a malformed email, or the same email twice; a CPF whose check digits do not match; a non-positive ExpiresAfter; a signer set with approvers in it on a host with no signature means or no way to identify an approver.

The per-job approval page is anonymous

The gate is real — a job genuinely does not sign until enough people approve — but /approve/{jobId} requires no credential: anyone who can reach the link can approve or reject as anyone in the job's pool. The startup banner warns on every approval-configured profile, at every boot. Enable ApproverPortal or Entra ID sign-in to narrow this, and read Approvals before exposing the host to a network the approvers' browsers can reach.

Signing in a Lacuna Signer folder — Method = SignerFolder​

New in 2.16.0

A third signing method. The operator walkthrough is in Lacuna Signer integration.

A profile on this method signs documents a person creates in Lacuna Signer, in the folder its SignerFolder.Id names, with the profile's certificate holder as a participant. This host finds them with a periodic sweep (Signer:FolderSweepIntervalSeconds) and, when Signer:WebhookSecret is set, within seconds of a Signer notification; it signs Signer's to-sign hash with the profile's own key, and Signer builds the signature into the document and keeps it — nothing enters input/ and nothing reaches output/. The holder is matched on the CPF the certificate carries (e-CPF and e-CNPJ certificates both carry one), never on a typed field. What the seed refuses — the same things a save from the dashboard refuses (Lacuna Signer integration) — is:

  • No SignerFolder.Id, or one that is not a GUID — Signer names a folder by its id; copy it from the folder at Signer.
  • A folder another entry is already bound to — two entries naming one SignerFolder.Id refuse the second, naming the first; ids are compared as GUIDs, not as text.
  • The default profile — it is where a bare upload and an unrouted watched folder land, and this method takes no files.
  • No Signer:* settings on this host — the documents are found and signed with this host's Signer:Endpoint and Signer:ApiKey.
  • Verify, Encrypt, ValidateCertificate or CheckCnab240 set to true — Signer keeps the document, chooses the container and validates the certificate at the start of the signature, and never hands this host a file. Note that Verify and ValidateCertificate default to true, so a SignerFolder entry states both as false.
  • An Approval block — the document's flow is decided at Signer.
  • A watched folder bound to it — a Storage:Inputs[].Profile naming the profile. Its documents start at Signer, so no file is ever read for it.
  • A per-host certificate source under Cluster:Enabled, as for any profile. Every other source qualifies, an uploaded PKCS#12 and Azure Key Vault included.

A certificate that carries no CPF is not among them: the seed opens no certificate, so such a profile boots and is reported degraded instead — as is a stored one on a host that has since lost its Signer:* settings, and one whose folder Signer later says does not exist (Lacuna Signer integration).

Format is ignored: Signer chooses PAdES for a PDF, XAdES for XML and CAdES for anything else, and the job records which. On a PDF, whether that PAdES signature follows the ICP-Brasil policy depends on the Signer organization's UseBrazilianPdfSigningPolicies setting, not on Bulk Signer — the product's ADR-Básica default does not reach it.

{
"Signer": {
"Endpoint": "https://signer.lacunasoftware.com",
"FolderSweepIntervalSeconds": 300
},
"Signing": {
"Profiles": [
{
"Name": "contratos",
"Method": "SignerFolder",
"SignerFolder": { "Id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" },
"Verify": false,
"ValidateCertificate": false,
"Certificate": {
"Source": "Pfx",
"Pfx": { "Path": "/etc/bulksigner/certificates/empresa-ecnpj.pfx" }
}
}
]
}
}

with Signer__ApiKey and Signing__Profiles__0__Certificate__Pfx__Password supplied through the environment.

Storage:Inputs[].Profile — per-folder routing​

KeyTypeDefaultEnv overrideNotes
Storage:Inputs[].Profilestring?null (→ "default")Storage__Inputs__0__ProfileOptional, and seed input: read once, on the first boot against an empty profile table, onto the profile it names — the profile's row then carries the folder, one folder per profile, and the key is ignored on later boots. Null or empty binds the default profile. A folder the seed cannot bind — its profile is not in Signing:Profiles[], or an earlier folder already bound that profile — is left unassigned, reported at startup, and shown as such on the Input page and /api/ready; its files wait until a profile chooses it. The host starts either way.
Changed in 2.2.0 — the profile chooses its folder

Up to 2.1.x this key was how a folder was routed, read on every boot. Now the binding lives on the profile's row in the operational store. After the first boot a folder is routed, moved or unassigned from the profile's page (Input folder, under Edit behaviour) and only there: editing this key changes nothing, and the startup log says so with one warning line counting the folder keys it ignores. A move takes effect on the next file, on every instance, with no restart. Keep the key if you want a fresh deployment to come up routed; delete it once the profiles are in the store and that line goes away.

Nothing else under Storage:Inputs[] moved. Name, Path, Provider, AzureFiles, PollIntervalSeconds and the two ignore lists are host configuration: read on every boot, validated by the rules under Storage:Inputs[] exactly as before, and edited nowhere on the web. The one key that left was never a fact about the folder — it said which signing rule the folder's files get, which is the profile's to state.

A folder no profile has chosen is unassigned: its watcher enqueues nothing, a rescan skips it and says so, and its files wait. A profile bound to a folder this host no longer configures is reported on the startup banner, as a profile-input-folder: row on /api/ready that does not fail the probe, and as an alert on the dashboard's profiles page.

Example: three profiles routed by folder, one per certificate source​

Profiles are independent, so a single deployment can mix key-custody models — an HSM for NF-e, a PFX on disk for contracts, and a vault-held key for invoices — with each watched folder feeding the one it needs. Which folder that is becomes each profile's choice on its page after the first boot; the Profile key on each Storage:Inputs[] entry seeds the choice once, on the same boot that imports these three.

"Signing": {
"PkiSdkLicense": "<env-var>",
"Profiles": [
{
"Name": "nfe",
"Format": "Xades",
"Verify": true,
"Encrypt": false,
"ValidateCertificate": true,
"Certificate": {
"Source": "Pkcs11",
"Pkcs11": { "ModulePath": "/usr/lib/x86_64-linux-gnu/pkcs11/libsofthsm2.so", "Thumbprint": "...", "PinEnvVar": "BULK_SIGNER_PKCS11_PIN" }
}
},
{
"Name": "contracts",
"Format": "Pades",
"Verify": true,
"Encrypt": true,
"ValidateCertificate": true,
"Certificate": {
"Source": "Pfx",
"Pfx": { "Path": "/etc/bulksigner/contracts.pfx", "Password": "" }
}
},
{
"Name": "invoices",
"Format": "Pades",
"Verify": true,
"Encrypt": false,
"ValidateCertificate": true,
"Certificate": {
"Source": "AzureKeyVault",
"AzureKeyVault": {
"Endpoint": "https://my-vault.vault.azure.net/",
"AppId": "8f2c1b3e-1111-2222-3333-444455556666",
"AppSecret": "",
"KeyName": "bulk-signer-invoices-key",
"CerPath": "/etc/bulksigner/certificates/invoices.cer"
}
}
}
]
},
"Storage": {
"Root": "/var/lib/bulksigner",
"Inputs": [
{ "Name": "nfe-incoming", "Path": "/var/lib/bulksigner/input-nfe", "Profile": "nfe" },
{ "Name": "contracts-incoming", "Path": "/var/lib/bulksigner/input-contracts", "Profile": "contracts" },
{ "Name": "invoices-incoming", "Path": "/var/lib/bulksigner/input-invoices", "Profile": "invoices" }
]
}

The invoices profile leaves AppSecret empty in the file and takes it from the environment instead. Array elements are bound by positional index, so the secret for the third profile is:

export Signing__Profiles__2__Certificate__AzureKeyVault__AppSecret='…'

Because that profile carries a secret, the first boot also needs Signing__ProfileSecretsKey set — the import encrypts the application secret under it and refuses without it. The other two carry none.

warning

That index is positional, not name-based. Inserting a new profile above invoices shifts it to index 3, the index-2 variable stops reaching it, and the seed fails with Signing:Profiles[3].Certificate.AzureKeyVault.AppSecret is required. Re-check every indexed environment variable after reordering the list. This only matters until the seed has run: after the first boot the secret lives, encrypted, on the stored profile, and changing it — a rotated client secret, say — is done from the profile's page (Edit certificate) followed by a restart, not by changing the variable.

The startup banner lists every resolved profile with its format, certificate source, and verify/encrypt/validate-cert flags. Profiles with Verify=false or ValidateCertificate=false emit additional warnings so the low-trust posture is captured in durable logs. A profile whose certificate did not open is listed with a DEGRADED · prefix and the reason beside it.

Signer — Lacuna Signer connection​

One Lacuna Signer tenant per host — the endpoint and API key are global, not per-profile. The validator is self-gating on this section: leave it out entirely and nothing here is enforced, which is what a local-only deployment does. Set any part of it and the whole block is validated, because a half-configured one is a deployment that would discover the gap at its first handoff. See Lacuna Signer integration.

Changed in 2.1.0 — the section is judged on its own, not by which profiles exist

Until 2.0.x these keys were enforced only when some Signing:Profiles[] entry had Method = LacunaSigner — a question configuration can no longer answer once profiles live in the store. Now the requirement sits on the profile: a profile that selects Method = LacunaSigner is refused — at the seed, or on the profile's page — when this section is absent. The same fact decides whether the remote-signer poll worker runs at all, so a host with these settings is ready for a profile pointed at Lacuna Signer after it booted, with no restart. Two consequences at the upgrade: a half-written Signer: block now refuses the boot even if nothing uses it (the message names both keys and offers removing the section), and a host with the whole block set but no profile using it now runs the poll worker idle. Remove the section if this host signs everything locally.

The same rule covers Method = SignerFolder (2.16.0): it is refused when this section is absent, and these settings are what start the Signer-folder sweep. A stored SignerFolder profile that finds itself on a host without them is degraded rather than refusing the boot (Lacuna Signer integration).

Endpoint and ApiKey serve both methods. PollIntervalSeconds, TimeoutHours and MaxConsecutiveApiFailures are read only for LacunaSigner; FolderSweepIntervalSeconds and WebhookSecret only for SignerFolder.

KeyTypeDefaultEnv overrideNotes
Signer:Endpointstring""Signer__EndpointREQUIRED when any profile uses LacunaSigner or SignerFolder. Base URL for the Lacuna Signer instance. Cloud default: https://signer.lacunasoftware.com. On-prem deployments point at the customer's instance.
Signer:ApiKeystring""Signer__ApiKeyREQUIRED, SECRET when any profile uses LacunaSigner or SignerFolder. Expected shape: application-id|secret. The literal value is scrubbed from logs.
Signer:WebhookSecretstring"" (notifications off)Signer__WebhookSecretNew in 2.16.0. SECRET. The token Lacuna Signer presents on a notification — its webhook, registered in Signer's organization integrations with Bearer authentication and this value as the token (walkthrough in Lacuna Signer integration). Sent as Authorization: Bearer <secret> and compared in fixed time — never as X-API-Key, which is this product's own REST credential. Empty, POST /api/signer/notifications answers 404 and the folder sweep alone finds documents, which costs latency and never a document. Setting it counts as setting the Signer block, so Signer:Endpoint and Signer:ApiKey become required. At least 32 characters; scrubbed from logs.
Signer:PollIntervalSecondsint30Signer__PollIntervalSecondsHow often the poll worker walks every AwaitingSigner row. Bounds: 1–3600.
Signer:TimeoutHoursint168 (7 days)Signer__TimeoutHoursHow long a job may sit in AwaitingSigner before it is failed with code = signer.timeout. Bounds: 1–8760.
Signer:MaxConsecutiveApiFailuresint5Signer__MaxConsecutiveApiFailuresPer-document consecutive transient-error budget before the poll worker gives up on that document. In-memory counter — restart resets it. From 2.16.0 a 5xx, 408, 429 or a proxy's error page counts against it too — see API failures.
Signer:FolderSweepIntervalSecondsint300 (5 minutes)Signer__FolderSweepIntervalSecondsNew in 2.16.0. How often every Lacuna Signer folder bound to an enabled SignerFolder-method profile is listed for pending documents. The sweep is what guarantees a document is signed, so this is the longest a document waits for this host to notice it. It runs whether or not the pipeline is paused — discovery only queues jobs; pause stops them being claimed — and under Cluster:Enabled every instance sweeps: a document two instances find is taken once, because the store records each (document, flow action) it has taken and refuses the second. Bounds: 60–86400.

WebPki — Lacuna Web PKI in the approver's browser​

An approver who co-signs a payment file (a profile whose signer set is Approvers or ProfileKeyAndApprovers) can do so with a certificate on their own token or in their own certificate store, through the Lacuna Web PKI browser extension. The library that reaches it ships with the product and is never loaded from a CDN, so an approver on a LAN with no internet access still gets the page. What Web PKI needs from the host is a licence bound to the deployment's domains, and this section is where it goes.

The licence is not a secret. It is sent to every approver's browser in clear, which is where Web PKI checks it against the page's domain, so it is deliberately not masked in the logs. Every surface that reports it says configured-or-not rather than showing it: the startup banner's web pki license row and the System page.

It is not a boot refusal either. The rule lives where every profile rule lives — at the seed and on the profile's page: a profile whose signer set has approvers in it is refused, naming this key and CloudHub:ApiKey, while neither is configured. A licence (a certificate in the browser) or CloudHub (a certificate in the cloud) is a signature means, and one of the two is enough. A deployment whose signer sets are all ProfileKey needs no WebPki section at all. On localhost Web PKI works with no licence.

KeyTypeDefaultEnv overrideNotes
WebPki:Licensestring""WebPki__LicenseRequired for a profile whose approvers sign, unless CloudHub is configured instead. Either of the two forms Lacuna issues — the binary (base64) string or the JSON document — passed to the browser as given. Bound to the deployment's domains: obtain it from Lacuna for the host names approvers will open the page on. Deliberately not marked SECRET.
{
"WebPki": {
"License": "<the licence Lacuna issued for this deployment's domains>"
}
}

CloudHub — Lacuna CloudHub for cloud certificates​

An approver whose ICP-Brasil certificate was issued into a provider's HSM — a certificado em nuvem — has nothing a browser can reach, so Web PKI cannot sign for them. Lacuna CloudHub fronts those providers behind one API: the product opens a session for the approver's CPF, sends their browser to the provider they pick, receives it back at this deployment's own address, and signs the file server-to-server with the certificate the provider authenticated. This section is what makes that signature means exist on a host; with no key, no page offers it and its callback route is unreachable.

It serves a batch as well as a file. Since 2.14.0 the approver portal's Approve N selected offers the cloud beside the browser — and alone, on a host with no Web PKI licence — with one provider login for the whole batch. Nothing here configures that separately.

The key is the switch, and it is a secret. Unlike the Web PKI licence, CloudHub:ApiKey is a bearer credential for every session this host opens, so it is masked in every log. Set it through the environment variable. The validator is self-gating on the key: leave it out and nothing here is enforced; set it and the whole section is validated.

PublicBaseUrl is required beside the key, and it is configured rather than derived. CloudHub needs an absolute address to send the browser back to, and this product renders every other link relative to the request. Deriving the address from forwarded headers was rejected: a derived address that is wrong is discovered by an approver stranded at their provider, while a configured one that is wrong is refused at boot. Give it the scheme, host and any path prefix approvers open the portal on, with no query and no fragment.

It is not a boot refusal for a profile. A profile whose approvers sign needs this section or a Web PKI licence, either being enough, and that rule is checked at the seed and on the profile's page. A host with CloudHub and no Web PKI licence is a legitimate deployment in which every approver signs in the cloud. Nothing probes CloudHub at boot or on /api/ready: the startup banner's cloudhub row, the System page and /api/ready/details say configured-or-not, naming the endpoint, and an outage on Lacuna's side never changes this host's readiness.

KeyTypeDefaultEnv overrideNotes
CloudHub:ApiKeystring""CloudHub__ApiKeySECRET. The CloudHub API key Lacuna issued for this deployment. Setting it is what offers the cloud signature means.
CloudHub:Endpointstringhttps://cloudhub.lacunasoftware.com/CloudHub__EndpointWhere CloudHub is. Leave it out to reach Lacuna's public instance. Must be an absolute http(s) URL when the key is set.
CloudHub:PublicBaseUrlstring""CloudHub__PublicBaseUrlREQUIRED when ApiKey is set. The absolute address approvers reach this deployment on — scheme, host and path prefix, no query, no fragment — which CloudHub sends the browser back to. Refused at boot when relative or malformed.
{
"CloudHub": {
"ApiKey": "<set via CloudHub__ApiKey; never in this file>",
"PublicBaseUrl": "https://bulksigner.example.com"
}
}

The deployment package's env-file samples (deploy/linux/bulksigner.env.sample, deploy/docker/.env.sample) carry a commented CloudHub__ApiKey= line for exactly this.

Encryption​

Off by default. The validator runs only when Enabled = true.

KeyTypeDefaultEnv overrideNotes
Encryption:EnabledboolfalseEncryption__EnabledMaster switch. When true, the worker AES-256-GCM-encrypts the signed artifact between verify and promote.
Encryption:Passwordstring""Encryption__PasswordSECRET. PBKDF2 password. Allowed in config (use appsettings.Production.json, which is gitignored) but env-var form is preferred.
Encryption:PasswordEnvVarstringBULK_SIGNER_ENCRYPTION_PASSWORDEncryption__PasswordEnvVarName of the env var that supplies the password. If non-empty at boot, it overrides Encryption:Password.
Encryption:Saltstring""Encryption__SaltREQUIRED when Enabled = true. Base64-encoded PBKDF2 salt; must decode to at least 16 bytes. Salts are not secret. Changing the salt invalidates every prior envelope.
Encryption:Iterationsint600000Encryption__IterationsPBKDF2-HMAC-SHA256 iteration count. Rejected below 10000.
danger

Password loss is unrecoverable — there is no server-side decrypt endpoint and no escrow. See Encryption.

Auth​

KeyTypeDefaultEnv overrideNotes
Auth:ApiKeystring""Auth__ApiKeyREQUIRED, SECRET. Static API key, minimum 16 characters. Sent in the X-API-Key header by programmatic clients; pasted at /login by operators to receive a cookie. Since 2.3.1 the shipped appsettings.json carries no value for it, so every deployment must set its own.
Auth:CookieNamestringlbs-authAuth__CookieNameCookie name issued by /api/auth/login. SameSite=Strict, HttpOnly, secure when the request was HTTPS.
Auth:ApiKeyHeaderstringX-API-KeyAuth__ApiKeyHeaderHTTP header the API-key scheme reads. Rename only if a reverse-proxy convention forces it.

See Security for API-key rotation and cookie session lifetime.

Auth:EntraId — optional Microsoft Entra ID sign-in​

Presence-gated — there is no Enabled flag. Omit the section (the default) and every surface behaves exactly as without it; an air-gapped deployment never needs a Microsoft tenant. Write the section and all three keys become required: a partially-filled section fails the host at boot, naming the missing key. "Present but empty" silently meaning off is exactly how an operator ends up believing a control is active when it is not.

KeyTypeDefaultEnv overrideNotes
Auth:EntraId:TenantIdstring(absent)Auth__EntraId__TenantIdREQUIRED when the section is present. The directory (tenant) GUID, or a verified domain (e.g. contoso.onmicrosoft.com). The multi-tenant pseudo-tenants common / organizations / consumers are refused — the mode is single-tenant by design.
Auth:EntraId:ClientIdstring(absent)Auth__EntraId__ClientIdREQUIRED when the section is present. The app registration's application (client) id. Must parse as a GUID — a typo here would otherwise surface only at sign-in time as an opaque AADSTS error.
Auth:EntraId:ClientSecretstring(absent)Auth__EntraId__ClientSecretREQUIRED when the section is present, SECRET. The confidential-client secret for the authorization-code flow. Set it via the environment variable; never commit it.

There is no fourth key. The authority, the callback path, the scopes, the cookie and its lifetime are all derived.

What turning it on changes​

SurfaceSection absent (default)Section present
/loginAPI-key formSign in with Microsoft button. The API-key form is gone.
POST /api/auth/loginExchanges the API key for a cookieIssues no cookie even for a correct key. Off, not hidden.
Existing operator cookiesValid for their 8-hour sliding windowStop satisfying policies immediately. Plan the cutover as a sign-everyone-out.
REST X-API-KeyWorksUnchanged. Automated clients never notice the mode.
Operator pagesAny authenticated cookieRequires the Administrator app role.
/approvals and the per-job approval pageApprover-portal link onlyLink or an Approver role session; the frozen pool still scopes which jobs are visible.
Recorded approval identificationSelfDeclaredEmail / LinkDerivedEmailAdds EntraIdEmail for decisions made in an Entra session.
Sign-outClears the cookieClears Bulk Signer's session only. The Microsoft session survives, so clicking "sign in" again succeeds silently — normal SSO behaviour, not a bug.

The two app roles​

Roles come from the token's role claims — app-role assignments and nothing else. There is no security-group mapping, deliberately: a group mapping would make a tenant-side group edit an invisible authorization change. Values in the app-registration manifest must match these strings exactly:

Role valueOpensLanding page after sign-in
AdministratorEvery operator page and action the API-key cookie grants today. No tiers./
ApproverThe approver surfaces only. The role opens the door; the frozen pool still decides which jobs the person sees, matched on their email claim./approvals
(both)Both. Dual-hatting is allowed; separation of duties is held by the role checks./
(neither)Nothing. An account that authenticates but holds no role is refused at /access-denied.—

A validated returnUrl always wins over the role-based landing, so deep links keep working.

How a signed-in person is named​

The operator's recorded name is the UPN. Every audit event a signed-in Administrator writes — a profile created or edited, the pipeline paused, jobs cleared, a backup run — names the token's preferred_username claim, and the user menu shows the same value. The UPN rather than the display name, because it is unique within the tenant at any moment, where the display name is free text two people may share. It is the name at the time of the act, not a durable key. A token that carries no preferred_username still signs in and its events read (anonymous), and the sign-in logs a warning naming the claim types it received. This is the operator's name only — approvers are still matched to pools by email, as above.

An Entra approver is greeted by the display name. The portal's greeting shows the token's name claim, falling back to the address when the tenant withholds it — never the UPN, which for a guest is the #EXT# form. The decision is recorded against the email either way.

The second factor's window rides the tenant session. When ApproverSecondFactor:Enabled is on, an Entra approver's verification window is keyed to the token's sid claim, a default ID-token claim that needs no registration change; when a token carries none, the sign-in mints an identifier and logs why. Signing out deletes the session's window, because the tenant session outlives the product's local sign-out and a colleague's silent re-sign-in on the same workstation would otherwise inherit it.

Upgrading from before 2.2.1

A dashboard session open across the upgrade to 2.2.1 or later keeps its old ticket, which does not carry the operator's identity into the page. Sign out and back in once after upgrading.

Example — minimal configuration​

{
"Auth": {
"ApiKey": "…",
"EntraId": {
"TenantId": "11112222-3333-4444-5555-666677778888",
"ClientId": "99990000-aaaa-bbbb-cccc-ddddeeeeffff",
"ClientSecret": ""
}
}
}
Auth__EntraId__ClientSecret='<the client secret from the app registration>'

Auth:ApiKey is still required — the REST surface's X-API-Key is untouched by this mode. All three Entra keys also bind from the environment alone, which is the natural form for a container or a systemd unit. The app-registration walkthrough is in Installation.

Storage​

KeyTypeDefaultEnv overrideNotes
Storage:RootstringdataStorage__RootREQUIRED. Root under which processing/, output/, error/, db/, logs/ are created. Override per target — /var/lib/bulksigner on Linux, C:\ProgramData\Lacuna\BulkSigner\data on Windows, /var/lib/bulksigner in Docker.
Storage:ProviderenumLocalFileSystemStorage__ProviderLocalFileSystem or AzureFiles. Chooses where the work share — processing/, output/, error/ — lives. logs/ and db/ always stay local. See below.
Storage:Inputs[]array of {Name, Path, Provider?, AzureFiles?, PollIntervalSeconds?, IgnoredExtensions?, IgnoredPrefixes?, Profile?}[{Name="default", Path="{Root}/input"}]Storage__Inputs__0__Name, Storage__Inputs__0__Path, …One or more watched input folders. Jobs are tagged with the folder's Name and with the signing profile that chose the folder; a folder no profile has chosen is unassigned and enqueues nothing. See below for validation rules, and Storage:Inputs[].Profile for what the seed does with the one routing key.

Storage:Inputs[] — validation rules (enforced at startup)​

  • Name must match ^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$ and be unique across the list — lowercase letters, digits, and internal hyphens only, 1–40 characters, starting and ending with an alphanumeric. Names appear in URL query strings, metric labels, and the dashboard UI.
  • Path must be non-empty and resolve to a directory that is not the same as and not a sub-/super-directory of any other entry's path. Overlapping folders would produce double-enqueues and ambiguous attribution.
  • Soft cap: 16 entries. Higher counts inflate metric cardinality and the Input page beyond useful density.
  • When Storage:Inputs is omitted entirely, the service creates one folder named default at {Storage:Root}/input.

Storage:Provider / Storage:AzureFiles — the work share​

Optional, and absent from every deployment that keeps its storage local. Setting Storage:Provider = AzureFiles moves the work share — processing/, output/ and error/ — into an Azure Files share reached through the service's own SDK, with no SMB mount and no host-level dependency. Storage:Root stays local and keeps holding logs/ and db/.

KeyTypeDefaultEnv overrideNotes
Storage:AzureFiles:AccountNamestringn/aStorage__AzureFiles__AccountNameREQUIRED when a provider resolves to AzureFiles. Account name with no suffix; the endpoint is https://<AccountName>.file.core.windows.net.
Storage:AzureFiles:ShareNamestringn/aStorage__AzureFiles__ShareNameREQUIRED. The work share. SMB protocol only — an NFS share is refused at boot by name.
Storage:AzureFiles:Directorystring?null (share root)Storage__AzureFiles__DirectoryOptional prefix within the share, under which processing/, output/ and error/ are created. Lets several deployments share one share. Work-share only — writing it on a Storage:Inputs[] entry is refused at boot, because a folder's directory is its Path.
Storage:AzureFiles:Credentialenumn/aStorage__AzureFiles__CredentialREQUIRED, never defaulted. ManagedIdentity, ServicePrincipal or AccountKey. A partial block for the chosen mode fails the host at boot naming the missing key.
Storage:AzureFiles:TenantId / :AppIdstringn/aStorage__AzureFiles__TenantId, …__AppIdServicePrincipal mode only.
Storage:AzureFiles:AppSecretstringn/aStorage__AzureFiles__AppSecretSECRET. ServicePrincipal mode only. Permitted in config, environment override recommended.
Storage:AzureFiles:AccountKeystringn/aStorage__AzureFiles__AccountKeySECRET. AccountKey mode only. Warns at startup: a shared key is full data-plane access to the whole account, cannot be scoped to one share, and never expires.

ManagedIdentity is system-assigned only — a user-assigned identity is not read, and naming one produces an authentication failure at the first call rather than a configuration error. The credential is deliberately not DefaultAzureCredential, so it never falls back to a developer's own az login identity.

Both token modes need one of the privileged file data roles: grant Storage File Data Privileged Contributor, scoped to the share. A read-only role is not enough even for an input folder, since the pipeline leases the input file while staging it and deletes it after verification. See Security.

One work share, not many. processing/, output/ and error/ must sit together, because promoting a verified artifact and relocating a failed job's staged copy are renames, and Azure's rename cannot cross shares or storage accounts. Input folders stay plural and independent — each may name its own account and share — because staging from one is a copy.

What startup refuses, and what it only reports. An unrecognised provider or credential mode, a partial credential block, an NFS share, an azurefiles:// path in Storage:Root, Logging:File:Path or — while Database:Provider is Sqlite — ConnectionStrings:Default, and an input folder that collides with one of the work roots on the work share all stop the host. An unreachable share does not: it is reported on the ops console, on the System page and by /api/ready, and the host comes up — a share that is down at 03:00 must not turn a restart into a service that will not start.

A fourth thing appears in the work share, and it is not a folder

bulksigner-instance.json sits beside processing/, output/ and error/. It records the host name and process id of the instance that claimed the share, and is held under a non-expiring lease for that instance's life. Leave it alone: it is what tells you at the next boot that a second instance is signing from this share. See Operations.

Downloads are always streamed through the application — no shared-access-signature URL is ever minted for a signed artifact, so GET /api/jobs/{id}/output behaves identically whichever provider holds output/. There is no retry knob, deliberately: the SDK policy is stated in code (three attempts, exponential from 500 ms to 5 s, 30-second network timeout) and this product already retries above it.

Per-folder overrides​

Each watched input folder chooses its own provider and inherits the rest, so reading a customer's share in their account while the work share stays in yours is a per-folder override rather than a second deployment.

KeyTypeDefaultEnv overrideNotes
Storage:Inputs[N].Providerenum?inherits Storage:ProviderStorage__Inputs__N__ProviderLocalFileSystem or AzureFiles, per folder. One folder can read a share while another stays on local disk during a migration.
Storage:Inputs[N].Pathstringn/aStorage__Inputs__N__PathREQUIRED. A filesystem path on a local folder; on an AzureFiles folder, the directory within the share, /-separated. A backslash is refused at boot — it is a local separator with no meaning on a share.
Storage:Inputs[N].AzureFiles:*objectinherits Storage:AzureFiles field by fieldStorage__Inputs__N__AzureFiles__AccountName, …Same members as the block above, minus Directory. Inheritance tests null, not empty: an omitted key is inherited, and an empty string is this folder saying it has no such value. Directory is refused here.
Storage:Inputs[N].PollIntervalSecondsint?inherits WatchedFolder:PollIntervalSecondsStorage__Inputs__N__PollIntervalSecondsREQUIRED in effect on an AzureFiles folder — one that resolves to no interval at all is refused at boot. On a local folder, presence here is the opt-in: writing an interval adds periodic enumeration to the folder's watcher behaviour, which is the fix for a folder mounted from a network share. Bounds: 5–3600.
Whether a folder polls and how often are two separate questions

Presence of Storage:Inputs[N].PollIntervalSeconds is what opts a local folder in; the global WatchedFolder:PollIntervalSeconds (default 30) is consulted only for the cadence. An existing local deployment that writes no per-folder interval keeps its event-driven behaviour unchanged.

Example: the work share on Azure Files, inputs still local​

"Storage": {
"Root": "/var/lib/bulksigner",
"Provider": "AzureFiles",
"AzureFiles": {
"AccountName": "contosofiles",
"ShareName": "bulksigner",
"Directory": "prod",
"Credential": "ManagedIdentity"
},
"Inputs": [
{ "Name": "default", "Path": "/var/lib/bulksigner/input", "Provider": "LocalFileSystem" }
]
}

Signed artifacts land in bulksigner/prod/output in the contosofiles account; logs/ and db/ stay under /var/lib/bulksigner. Drop the Provider line from the input folder and it inherits AzureFiles, in which case its Path becomes a directory within the share and it is enumerated on WatchedFolder:PollIntervalSeconds — Azure Files publishes no change notifications, so a folder that resolves to no interval at all is refused at boot.

Example: input folders on a share too, authenticating with an account key​

For a host that cannot reach the tenant at all — an on-premises server with no managed identity and no app registration — AccountKey is the remaining mode:

"Storage": {
"Root": "/var/lib/bulksigner",
"Provider": "AzureFiles",
"AzureFiles": {
"AccountName": "contosofiles",
"ShareName": "bulksigner",
"Credential": "AccountKey"
},
"Inputs": [
{ "Name": "remessas", "Provider": "AzureFiles", "Path": "entrada/remessas", "PollIntervalSeconds": 30 },
{ "Name": "contabil", "Path": "entrada/contabil", "AzureFiles": { "ShareName": "financeiro" }, "PollIntervalSeconds": 300 },
{ "Name": "legacy", "Provider": "LocalFileSystem", "Path": "/mnt/legacy/incoming" }
]
}
Storage__AzureFiles__AccountKey=<storage account key> # env var recommended; never commit

Five things this example shows:

  • The key is one secret for every share it opens. contabil overrides ShareName and nothing else, so AccountName, Credential and the key are inherited field by field. Writing "AccountKey": "" on a folder is that folder saying it has no key, not a request to inherit one.
  • AccountKey warns at every boot, on the ops console and in the durable log, naming each share it opens.
  • No Directory prefix, so the work roots sit at the share root. Input folders may share the work share, but an entry whose Path is, sits inside, or contains one of the three roots is refused at boot — that collision would otherwise delete one signed artifact per iteration while reporting every job Completed.
  • Both remote folders name their own interval, and the local one deliberately names none. legacy writing no interval is what keeps it event-driven.
  • The banner confirms it, printing azure credential = AccountKey, work share = contosofiles/bulksigner, the per-folder providers, and azure shares = 2 reachable — the shares are probed separately, so a key that opens one and not the other is visible at startup.

Storage:Inputs[].IgnoredExtensions / Storage:Inputs[].IgnoredPrefixes (per-folder)​

Optional arrays. The effective ignore list is the union of the global WatchedFolder:IgnoredExtensions / WatchedFolder:IgnoredPrefixes baseline and the per-folder additions. Per-folder lists add to the baseline; they cannot un-ignore something the global list already filters. Example: with the default baseline (.tmp, .part, .crdownload, .swp), a folder declaring IgnoredExtensions: [".bak"] filters .bak and .tmp etc.

Example: two folders, one with an extra ignore rule​

"Storage": {
"Root": "/var/lib/bulksigner",
"Inputs": [
{ "Name": "default", "Path": "/var/lib/bulksigner/input" },
{
"Name": "legal",
"Path": "/mnt/legal/incoming",
"IgnoredExtensions": [".bak"]
}
]
}

Pipeline​

KeyTypeDefaultEnv overrideNotes
Pipeline:PollIntervalSecondsint2Pipeline__PollIntervalSecondsHow often the worker polls the queue when idle. Lower = faster pickup, more SQLite reads. Bounds: 1–3600.
Pipeline:MaxConcurrencyint1Pipeline__MaxConcurrencyUpper bound on concurrent in-flight jobs. Default 1 is sequential. Increase for throughput on PFX-backed deployments. Bounds: 1–32. PKCS#11 / WindowsStore: keep at 1 unless the token / CSP allows concurrent sessions — see Certificates.
Pipeline:RejectAlreadyProcessedFileNamesbooltruePipeline__RejectAlreadyProcessedFileNamesNew in 2.13.0, on by default. Refuses a file whose name a Completed or still-active job already carries — compared across the whole host (every watched folder, every profile, every upload) and ignoring case, because output/ is one folder. From a watched folder or a rescan the file becomes a job that is Failed from the start with file.already-processed, moved into that job's error/ folder unsigned; an upload is refused with 409 and the same code. Failed and Canceled jobs reserve no name, and a retry is exempt. Deleting the job that holds the name (from /jobs) accepts it again. Turn it off for a producer that reuses one fixed file name every day.

WatchedFolder​

The stability detector guards against picking up a file that is still being written — the watcher waits until size and last-write-time stay identical across StabilityRequiredSamples consecutive polls.

KeyTypeDefaultEnv overrideNotes
WatchedFolder:StabilityPollIntervalMsint500WatchedFolder__StabilityPollIntervalMsInterval between stability checks. Bounds: 50–10000.
WatchedFolder:StabilityRequiredSamplesint3WatchedFolder__StabilityRequiredSamplesConsecutive identical samples needed before enqueue. Bounds: 1–100.
WatchedFolder:StabilityConcurrencyint8WatchedFolder__StabilityConcurrencyHow many candidate files each folder stabilizes and enqueues concurrently. The stability check blocks roughly StabilityRequiredSamples × StabilityPollIntervalMs per file, so processing them one at a time caps ingestion to about one file per that interval; overlapping the waits keeps the pipeline fed on bulk drops. Bounds: 1–64.
WatchedFolder:StabilityTimeoutSecondsint60WatchedFolder__StabilityTimeoutSecondsMaximum wait before giving up on a never-stable file. Bounds: 1–3600.
WatchedFolder:PollIntervalSecondsint?30WatchedFolder__PollIntervalSecondsHow often a folder that polls is enumerated — not whether it polls. Overridden per folder by Storage:Inputs[N].PollIntervalSeconds, and it is that key's presence which turns polling on for a local folder; setting this one alone changes nothing anywhere. An AzureFiles folder always polls and takes this value unless it names its own. Bounds: 5–3600.
WatchedFolder:IgnoredExtensionsarray[".tmp", ".part", ".crdownload", ".swp"]n/a (use config)File extensions the watcher ignores entirely.
WatchedFolder:IgnoredPrefixesarray[".", "~$"]n/a (use config)File-name prefixes the watcher ignores (dotfiles, Office lock files).

Upload​

KeyTypeDefaultEnv overrideNotes
Upload:EnabledbooltrueUpload__EnabledNew in 2.10.0. Whether this host takes files by upload at all. false turns the surface off on both sides at once: POST /api/files answers 409 with upload.disabled — before the profile is resolved, so a refused request learns nothing about which profiles exist — and the Jobs page shows no Upload files button. Watched folders, rescan and retry are untouched, so a deployment fed by its folders alone loses nothing. Read once at boot; turning it back on is a restart.
Upload:MaxByteslong104857600 (100 MiB)Upload__MaxBytesHard cap on the upload request body — POST /api/files and the dashboard's Upload files dialog alike. Raise for scan-heavy PDFs. Minimum 1024.

Dashboard​

KeyTypeDefaultEnv overrideNotes
Dashboard:PollIntervalSecondsint5Dashboard__PollIntervalSecondsServer-side refresh tick for live dashboard pages. Bounds: 1–60.

Branding — the customer's logo on the sign-in and approver pages​

New in 2.10.0. The sign-in page and the approver pages (/approve/{id}, /approvals and the link-required page) show the product mark. Name a customer logo here and those pages show it too: above a reduced product mark on the sign-in card, beside one in the approver headers. One logo per deployment, on white — every one of those cards is white, so a mark that only works on a dark background is a file to re-export. Nothing else changes: the app bar, the page titles and the favicon stay the product's.

The logo is read once, at startup, from a file on the host or from an Azure blob — the same two places a certificate's material can be, and for the same reason: an Azure App Service host has no file an operator can place. Name one of the two. Naming neither is legal and means no logo; naming both is refused at boot.

KeyTypeDefaultEnv overrideNotes
Branding:CustomerLogo:Pathstring—Branding__CustomerLogo__PathPath to the image file on the host — write it absolute. The extension decides the content type and must be .png, .jpg/.jpeg, .webp or .svg; anything else is refused at boot. On Docker, a read-only bind mount (the compose file in the deployment package has a commented example). Allowed under Cluster:Enabled, but the file must then be identical on every instance; nothing checks that.
Branding:CustomerLogo:Blob:Urlstring""Branding__CustomerLogo__Blob__UrlFull https:// URL of the blob. A query string is refused (no SAS). The blob name's extension follows the same rule as the path's. Same block shape as a certificate's …:Blob; nothing inherits from that block or from Storage:AzureFiles.
Branding:CustomerLogo:Blob:Credentialstring—Branding__CustomerLogo__Blob__CredentialManagedIdentity, ServicePrincipal or AccountKey. Never defaulted. A token credential needs Storage Blob Data Reader on the container.
Branding:CustomerLogo:Blob:TenantId / AppId / AppSecretstring—Branding__CustomerLogo__Blob__…ServicePrincipal mode only, all three required. AppSecret is SECRET — set it via the environment.
Branding:CustomerLogo:Blob:AccountKeystring—Branding__CustomerLogo__Blob__AccountKeyAccountKey mode only. SECRET, and it grants the whole storage account — prefer a token credential.

Refused at boot, naming the key: both Path and Blob set; an extension outside the five; a blob block missing its Url or Credential, a credential mode missing its values, or a URL with a query string. Each is a setting that could not have worked whatever the file holds.

Reported and survived: the file or blob is missing, unreadable or unreachable; the file is empty or over 256 KiB; the bytes are not what the extension says. The service starts, signs and serves; the pages show the product mark alone; and the reason is on the startup banner's customer logo row, in the startup log as a warning, and on the System page as an alert until the next restart. /api/ready carries no row for it — an image must not decide whether a signing service is ready. Fix the file or the setting and restart.

How it reaches the browser. GET /branding/customer-logo serves the bytes anonymously, because the pages that show it are shown before sign-in. The URL carries a hash of the bytes and is cached as immutable, so a restart with a new file changes it everywhere at once and nobody needs to clear a cache. An SVG is served under a policy that stops any script it carries from running. With no logo loaded the route answers 404 with branding.customer-logo-not-available.

{
"Branding": {
"CustomerLogo": {
// A file on this host. Absolute; .png, .jpg, .jpeg, .webp or .svg; 256 KiB at most.
"Path": "/etc/bulksigner/customer-logo.png"

// OR, for a host with no disk to place it on (App Service): an Azure blob, read once at boot.
// Remove Path above if you use this — both set is refused.
// ,"Blob": {
// "Url": "https://contoso.blob.core.windows.net/branding/customer-logo.svg",
// "Credential": "ManagedIdentity"
// }
}
}
}

ApproverPortal​

Backs the per-approver queue at /approvals. Off by default, so a deployment that already uses the approval gate upgrades without touching configuration. Read once at startup — change any of it and restart.

A top-level section rather than a per-profile block on purpose: a link identifies a person, and the same person routinely sits in the pools of several profiles.

KeyTypeDefaultEnv overrideNotes
ApproverPortal:EnabledboolfalseApproverPortal__EnabledMaster switch. When false no link resolves, no session is issued, and the only approval surface is the per-job link.
ApproverPortal:LinkSecretstring—ApproverPortal__LinkSecretREQUIRED when enabled, SECRET — never commit it. Every approver's link is HMAC-SHA256(this, their email), so anyone who reads it can approve payment files as any configured approver. Minimum 32 characters, enforced at startup. Must be durable: generating one per boot would invalidate every approver's bookmark on every restart. Changing it revokes every approver's link at once — the intended blunt instrument for "the secret leaked".
ApproverPortal:DecidedLookbackTimeSpan90.00:00:00ApproverPortal__DecidedLookbackHow far back the portal's Decided tab reaches. Bounds what a stolen link is worth. The tab is also capped at 200 rows per load and says so when the cap bites.
ApproverPortal:SessionLifetimeTimeSpan30.00:00:00ApproverPortal__SessionLifetimeLifetime of the cookie issued by the link exchange. Sliding, so an approver working through a queue is not signed out mid-decision.
ApproverPortal:PollIntervalTimeSpan00:00:10ApproverPortal__PollIntervalHow often an open portal re-reads the queue, so a colleague's decision, a release and a lapsed wait budget arrive without the approver pressing anything. Its own key rather than a share of Dashboard:PollIntervalSeconds, because this one multiplies across every approver with a tab open while that one is tuned by a handful of operators. Bounds 00:00:01 to 00:05:00, checked only when Enabled is true. There is no value that turns the poll off — a long interval is how to shed the load, and the page carries a manual refresh either way. Beware that a bare 10 parses as ten days and is refused, naming the value it got.

Enabled = true with no stored signing profile carrying an approval rule is a startup warning, not a refusal — a portal over no pools shows every approver an empty queue and looks broken, so the banner and the durable log say so.

Changed in 2.1.0 — a warning, no longer a refusal

Up to 2.0.x this refused the boot. Once profiles live in the store and Signing:Profiles[] is only a seed, the refusal would fire on exactly the state this page tells you to reach: pools in the store, the section deleted.

{
"ApproverPortal": {
"Enabled": true,
"LinkSecret": "replace-with-32+-random-characters-kept-secret",
"DecidedLookback": "90.00:00:00",
"SessionLifetime": "30.00:00:00"
}
}

Each approver's link is shown on the System dashboard page, one per configured person. Send each approver only their own; treat it as that person's password. See Approvals.

Console:Dashboard​

KeyTypeDefaultEnv overrideNotes
Console:Dashboard:EnabledbooltrueConsole__Dashboard__EnabledWhether the live terminal dashboard may replace per-job log narration on stdout. It activates only when this is true and the process is a foreground console (not Windows Service / systemd / Docker) and stdout is an interactive terminal. Service and container installs are therefore unaffected by this key. Set it to false when you run the binary in the foreground and want plain streaming logs.

Display language — deliberately not configurable​

There is no configuration section for the UI language, and that is a decision rather than a gap. The web surfaces render in en-US or pt-BR as a per-browser presentation preference: the language selector posts to the anonymous POST /api/culture, which writes the standard ASP.NET Core culture cookie for a year. Resolution order is cookie → the browser's Accept-Language → en-US, so a Brazilian operator gets Portuguese on first load without anyone configuring anything, and there is no server-wide setting to override what an individual reader chose.

What stays English permanently regardless of the reader's choice: persisted audit messages (they are evidence), log output, the console dashboard, REST problem prose, JobStatus wire values, and all CNAB240 vocabulary and formatting. See Dashboard.

LogViewer​

Backs the in-memory recent-exception store and the /logs dashboard page. All values are read once at startup — change them and restart.

KeyTypeDefaultEnv overrideNotes
LogViewer:EnabledbooltrueLogViewer__EnabledMaster switch. When false the in-memory sink is not wired, the /logs page shows a disabled notice, and the nav link is hidden.
LogViewer:MaxEntriesint20LogViewer__MaxEntriesSize of the bounded in-memory buffer and the ceiling on rendered entries. Oldest entries are evicted past this limit. Bounds: 1–1000.
LogViewer:RefreshIntervalSecondsint5LogViewer__RefreshIntervalSecondsAutomatic refresh tick for the /logs page. Bounds: 1–60. The page also has a manual refresh button.
LogViewer:Levelsstring[]["Error","Fatal"]LogViewer__Levels__0, …Log levels the store captures (case-insensitive). Valid names: Verbose, Debug, Information, Warning, Error, Fatal. Empty or unknown names fail startup. Logging:File:MinimumLevel still applies first — widening this below that minimum captures nothing, because those events never reach the sink.

See Dashboard.

Readiness​

KeyTypeDefaultEnv overrideNotes
Readiness:RequireApiKeyboolfalseReadiness__RequireApiKeyNew in 2.6.0. When true, GET /api/ready requires API-key or cookie auth — the same gate Metrics:RequireApiKey puts on /api/metrics. Off by default, unlike that one, because the probe's main consumer is a platform health check that cannot carry the key: Azure App Service's must be answered anonymously, and a 401 on every instance makes every instance unhealthy at once. Turn it on where the prober can send X-API-Key (a Kubernetes probe's headers, a monitoring agent) or where nothing probes. Read once at startup; the banner's ready row says (anonymous) or (API key). /api/ready/details is authenticated regardless, and /api/ready carries no detail regardless.
Changed in 2.6.0 — the anonymous probe answers with a verdict, not a description

/api/ready now returns ready and each check's name and ok — the per-check detail field is absent. That detail used to name the SQL Server host, every input share and a degraded certificate's location: none of it a credential, but together a map of the deployment readable by anyone who could reach the port. The same report with every detail is on /api/ready/details, behind the API key or an operator session, with the same 200 / 503 rule. An orchestrator that reads the status code is unaffected; a monitor that parsed detail moves to the details route and adds the header. A check's change of verdict is written to the durable log once per change, so the history is still on record.

Metrics​

KeyTypeDefaultEnv overrideNotes
Metrics:RequireApiKeybooltrueMetrics__RequireApiKeyWhen true, /api/metrics requires API-key or cookie auth. Set false only if your Prometheus scraper sits inside the trust boundary and the network is locked down.

See REST API for the full inventory of metrics instruments.

Statistics​

KeyTypeDefaultEnv overrideNotes
Statistics:EnabledbooltrueStatistics__EnabledMaster switch. When false the collector is a no-op (no recording, no locking), no row is written, and the dashboard panel is hidden. Statistics are one row per completed job in the operational store: they survive restarts, and every instance of a cluster reads the same figures. Turning the switch off does not delete rows already recorded — turning it back on shows them again; clearing the panel is Clear Jobs.

See Job statistics for what each number means.

Backup​

Backs the database backup feature: the /backup dashboard page, GET|POST /api/backup, and the scheduler. Off by default, and SQLite only — with Database:Provider = SqlServer, Backup:Enabled = true is a boot refusal rather than a silent no-op, because backing up the store is that DBMS regime's job. Since cluster mode requires SqlServer, the combination is unreachable there by construction.

Read once at startup. Only the selected destination's block is read at all.

KeyTypeDefaultEnv overrideNotes
Backup:EnabledboolfalseBackup__EnabledMaster switch. When false nothing is scheduled, the start button and POST /api/backup refuse with backup.disabled, and the page explains what to turn on. true under Database:Provider = SqlServer refuses the boot, naming both keys and the remedy.
Backup:DestinationstringDiskBackup__DestinationDisk, S3 or AzureBlob. Absent means Disk — the destination that needs no credential. Case-insensitive; an unrecognised value is refused at boot naming the key, your value and the valid names.
Backup:IntervalHoursint?(absent)Backup__IntervalHoursHow often a backup runs automatically. Absent means manual only — the feature is on, the button works, nothing runs on a timer. Bounds 1–8760. An interval rather than a time of day, deliberately: a time of day needs a timezone, which this product does not have. Anchored on the last successful run, so a restart does not reset it and a failed run does not consume it. Never having backed up means a backup is due immediately.
Backup:RetainCountint14Backup__RetainCountHow many artifacts to keep at the destination. 0 keeps every one — and does not even list the destination — for a bucket or container whose own lifecycle rules prune. Bounds 0–1000. A count rather than an age, because a count survives somebody changing IntervalHours. The prune runs only after a successful store and cannot fail the run.
Backup:Disk:Pathstring—Backup__Disk__PathREQUIRED when Destination = Disk. No default, on purpose: every plausible one would put the backup on the same disk as the database it is a backup of. A UNC path or mounted network volume is fine. Four boot refusals: absent; the product's own azurefiles:// scheme (a backup destination is deliberately not a storage provider); a path inside a watched input folder (the pipeline would ingest, sign and then delete the backup); and a path inside processing/, output/, error/ or db/.
Backup:S3:BucketNamestring—Backup__S3__BucketNameREQUIRED when Destination = S3.
Backup:S3:Regionstring—Backup__S3__RegionREQUIRED when Destination = S3 and no ServiceUrl is set. The region's system name, e.g. sa-east-1. Not defaulted: the SDK would fall back to the host's environment or shared profile, so an omitted region decides where a copy of the payment-approval record is stored by accident.
Backup:S3:Prefixstring""Backup__S3__PrefixKey prefix within the bucket. Normalised — bulksigner, bulksigner/ and /bulksigner/ all mean the same thing.
Backup:S3:Credentialstring—Backup__S3__CredentialREQUIRED when Destination = S3. AccessKey or InstanceRole. Never defaulted — the AWS SDK's own chain would authenticate as whoever the host happens to be. A presigned URL is not an accepted credential.
Backup:S3:AccessKeyIdstring—Backup__S3__AccessKeyIdREQUIRED when Credential = AccessKey. Deliberately not redacted from logs: it is the identifier half of the pair, and masking it would remove the one value that says which key a refused request used. Setting it under InstanceRole is a boot refusal.
Backup:S3:SecretAccessKeystring—Backup__S3__SecretAccessKeyREQUIRED when Credential = AccessKey. SECRET. Setting it under InstanceRole is a boot refusal.
Backup:S3:ServiceUrlstring—Backup__S3__ServiceUrlAn S3-compatible API endpoint instead of AWS — MinIO, Ceph, Wasabi, Backblaze B2. Absolute http:// or https://. When set, Region becomes optional.
Backup:S3:ForcePathStyleboolfalseBackup__S3__ForcePathStyleAddress buckets as a path segment (host/bucket/key) rather than as a subdomain. false is what AWS itself wants; almost every S3-compatible endpoint needs true — leaving it false against MinIO or Ceph produces a DNS failure naming a hostname you never configured.
Backup:AzureBlob:ContainerUrlstring—Backup__AzureBlob__ContainerUrlREQUIRED when Destination = AzureBlob. The full https URL of the container, e.g. https://contoso.blob.core.windows.net/bulksigner-backups. Refused at boot when it is not absolute https, does not name an account, names more than one path segment (put a path in Prefix), or carries a query string — which is how a SAS arrives, and refusing one keeps this value permanently non-secret. The container is not created for you.
Backup:AzureBlob:Prefixstring""Backup__AzureBlob__PrefixBlob-name prefix within the container. Normalised like the S3 one.
Backup:AzureBlob:Credentialstring—Backup__AzureBlob__CredentialREQUIRED when Destination = AzureBlob. ManagedIdentity, ServicePrincipal or AccountKey — spelled exactly as Storage:AzureFiles' is. Never defaulted. ManagedIdentity is system-assigned only. The identity needs Storage Blob Data Contributor — write, not the Storage Blob Data Reader a certificate blob needs.
Backup:AzureBlob:TenantId / AppIdstring—Backup__AzureBlob__TenantId, …REQUIRED when Credential = ServicePrincipal.
Backup:AzureBlob:AppSecretstring—Backup__AzureBlob__AppSecretREQUIRED when Credential = ServicePrincipal. SECRET. Setting it under another mode is a boot refusal — a secret this deployment does not use is one nobody will rotate.
Backup:AzureBlob:AccountKeystring—Backup__AzureBlob__AccountKeyREQUIRED when Credential = AccountKey. SECRET. Grants full data-plane access to the entire storage account and cannot be scoped to one container. Setting it under another mode is a boot refusal.

Nothing inherits from Storage:AzureFiles or from a signing material blob's block, even where the same Entra application is named: those credentials grant read access to different resources, and this one needs write.

Example: an interval to a local volume​

{
"Backup": {
"Enabled": true,
"Destination": "Disk",
"IntervalHours": 24,
"RetainCount": 14,
"Disk": { "Path": "/backup/bulksigner" }
}
}

Example: an S3-compatible endpoint (MinIO)​

{
"Backup": {
"Enabled": true,
"Destination": "S3",
"IntervalHours": 12,
"RetainCount": 0, // the bucket's lifecycle rules prune
"S3": {
"BucketName": "bulksigner-backups",
"ServiceUrl": "https://minio.internal:9000",
"ForcePathStyle": true,
"Credential": "AccessKey",
"AccessKeyId": "…",
// In practice set via Backup__S3__SecretAccessKey, not here.
"SecretAccessKey": ""
}
}
}

See Retention for how this fits the wider retention picture.

Cluster — multi-instance deployment​

Cluster mode — more than one active instance cooperating over one operational store and one work share. Off by default, and off is byte-for-byte the single-instance product: a deployment that never writes this section needs nothing and changes nothing on upgrade.

These three keys are the smallest part of turning the mode on. What it costs is in High availability and its limits — read that before setting Enabled = true — and how to deploy it is Azure App Service (cluster mode), which also carries the platform settings (session affinity, the health-check path, Always On) that have no key here because they are not this product's to set.

KeyTypeDefaultEnv overrideNotes
Cluster:EnabledboolfalseCluster__EnabledMaster switch. When true the boot refusals below apply; when false or absent, no cluster mechanism is registered and nothing changes. The supported multi-instance topology is exactly one: an Azure Web App (Linux container) scaled out on one App Service Plan.
Cluster:HeartbeatSecondsint15Cluster__HeartbeatSecondsHow often this instance writes one row saying it is alive. Range [5, 300], refused at boot outside it. Read only when the mode is on.
Cluster:StaleAfterSecondsint60Cluster__StaleAfterSecondsHow long an instance may go quiet before its siblings presume it dead. Range [15, 3600], and refused at boot when it is worth under three cadences — a threshold that short presumes death on one or two missed beats, and a beat goes missing for reasons that are not death (a collection pause, a store that took a moment, a container the platform briefly starved). The default is four cadences.

What Enabled = true refuses at boot​

Every cluster configuration that could not have worked is a refusal naming the keys and the remedy:

  • Database:Provider must be SqlServer. The store is the cluster's coordination point, and a SQLite file cannot be shared between hosts.
  • The work share and every watched input folder must be on AzureFiles. The local file store's lease excludes nothing outside its own process, and a folder local to one instance is invisible to its siblings. The zero-config synthesised default folder is local, so a first run with the switch on refuses too.
  • The per-host certificate sources Pkcs11 and WindowsStore are refused. A token or a machine store lives on one machine, and cluster instances are fungible. Use Pfx (ideally read from a blob) or AzureKeyVault. A stale Certificate block on a Method = LacunaSigner profile stays tolerated, as it is everywhere else. This is a rule about a profile, so it is made wherever a certificate source is stated: at boot for a Signing:Profiles[] entry or the legacy Signing:Certificate block, and on the dashboard's form when a profile is created or its certificate re-pointed. A profile saved before the switch was turned on is only a startup warning naming the profile and its source — a stored profile is never re-validated, and refusing would take away the page that is the remedy. It signs on the instance whose hardware it found and fails as a degraded profile on every other one.

One refusal is not about configuration at all, and is listed here because it reads like one: with the mode on, the work share's own marker records which operational store the share belongs to, and an instance whose store does not match refuses to start naming both. That is a fact about the share rather than about appsettings.json, and it is the one thing that catches two clusters pointed at one work share.

One cluster condition is deliberately a warning, not a refusal: cluster mode with Logging:AzureTable:Enabled = false logs a Critical at startup, because on the supported topology the instance disk is ephemeral and rolled log files are discarded on every recycle. The never-only-sink rule keeps the local file sink on either way.

Identity and liveness​

Identity is derived, never configured, and there is deliberately no key for it. An instance calls itself by the platform's own instance id (WEBSITE_INSTANCE_ID, which App Service sets on every instance) or by the machine name where there is no such variable, plus a fresh incarnation identifier for each boot. App Service creates and destroys instances by scale rule, so a name an operator typed would be a key nobody could keep truthful; the incarnation is what lets an instance tell its own previous life from a stranger.

Each instance keeps one row in the operational store — identity, incarnation, application version, when it started, when it last said it was alive — refreshed on Cluster:HeartbeatSeconds and presumed dead past Cluster:StaleAfterSeconds. The System page renders that table as its Instances view. Two things ride on it at boot, deliberately different in kind — one takes the identity, the other only warns:

  • A booting instance that finds its own identity already beating displaces it, naming the displaced incarnation on the console and in the log, and continues. That is a redeploy's overlap: App Service runs the old and new containers under one instance id and keeps the old one beating until the new one is warm. The displaced process stands down on its next beat — it claims no new job, finishes what it holds, and shows a red cluster-instance row on its /api/ready that does not fail the probe. A job's owner is the incarnation as well as the identity, so at every moment exactly one process claims work under a name, and whatever the displaced one leaves unfinished is taken over Cluster:StaleAfterSeconds after the displacement. A clean shutdown retires its heartbeat row, so a successor has nothing to displace. Two hosts genuinely presenting one name are displaced in turn, loudly on both sides, rather than refused; the one refusal left is a registration that lost every write race for its row.
  • Live instances on a different application version log a Critical and the boot continues. Upgrades on the supported topology are stop-the-world, so this is a deployment slot swapped into a running cluster, or a deploy that did not stop every instance. It is a warning rather than a refusal on purpose: 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.

If the operational store is unreachable at boot the registration is skipped along with the migration and the host still starts. The heartbeat makes the registration on its first beat that reaches the store — displacing a live holder of the identity exactly as the boot would have — so the instance is absent from the Instances view only until then.

Changed in 2.5.0 — displaced, no longer refused

Up to 2.4.x a booting instance that found its own identity beating refused to start (2.4.3 made it wait instead), so an in-place image change on App Service cost at least one refused start. Upgrades are still stop-the-world, and stop, deploy, start remains the tidier recipe; see Azure App Service.

The session key ring has no key of its own, and needs none​

Off the switch, the Data Protection ring is keys/ under Storage:Root, DPAPI-encrypted on Windows. On, it is rows in the operational store, in plaintext, guarded by the database's own access control — because a ring derived from a local root is structurally one host's, so behind a load balancer a cookie minted by one instance is rejected by the next, which reaches people as an intermittent sign-out with nothing anywhere reporting it. Both cookies ride it, so it strands operators and approvers alike.

It follows the switch rather than a setting deliberately: a deployment that can choose the placement independently of the topology is a deployment that can choose the broken combination. See Security and High availability.

Telemetry​

Opt-in Azure Application Insights export. Off by default; when off, the service has no Application Insights dependency and makes no outbound calls on its behalf.

KeyTypeDefaultEnv overrideNotes
Telemetry:EnabledboolfalseTelemetry__EnabledMaster switch. When true, a connection string is required — startup fails without one.
Telemetry:ConnectionStringstring?nullTelemetry__ConnectionStringSECRET. Application Insights connection string. Leave unset to supply it via the standard APPLICATIONINSIGHTS_CONNECTION_STRING environment variable instead.
Telemetry:RoleNamestringLacuna.BulkSignerTelemetry__RoleNameReported as the cloud_RoleName dimension, so several services sharing one resource stay distinguishable.

See Telemetry for what is collected and the KQL queries to read it.

RateLimiting​

Per-IP fixed-window policies.

KeyTypeDefaultEnv overrideNotes
RateLimiting:EnabledbooltrueRateLimiting__EnabledMaster switch. Disable for closed-network installs.
RateLimiting:Upload:PermitsPerWindowint30RateLimiting__Upload__PermitsPerWindowRequests allowed per window for POST /api/files.
RateLimiting:Upload:WindowSecondsint60RateLimiting__Upload__WindowSecondsWindow length for the upload policy.
RateLimiting:Upload:QueueLimitint0RateLimiting__Upload__QueueLimitHow many over-limit requests wait vs. being rejected immediately. 0 = reject immediately.
RateLimiting:Actions:PermitsPerWindowint60RateLimiting__Actions__PermitsPerWindowRequests allowed per window for action endpoints (retry, cancel, rescan, cleanup, pause, resume).
RateLimiting:Actions:WindowSecondsint60RateLimiting__Actions__WindowSecondsWindow length for the actions policy.
RateLimiting:Actions:QueueLimitint0RateLimiting__Actions__QueueLimitQueue depth for the actions policy.
RateLimiting:Approval:*same shape10 per 60 sRateLimiting__Approval__PermitsPerWindow, …Budget for the anonymous POST /api/approvals/{id} route, separate from the operator actions. Job ids are v4 GUIDs, and this policy is what keeps them unguessable against a machine rather than a person.
RateLimiting:Export:*same shape10 per 60 sRateLimiting__Export__PermitsPerWindow, …Budget for the Excel exports: the approver portal's queue export and, since 2.11.0, the Jobs page's GET /api/jobs/export. Bounds how fast copies of a queue can be made, and is kept separate from Approval so a burst of exports never spends the permits a colleague needs to record a decision.
RateLimiting:SignerNotification:*same shape600 per 60 sRateLimiting__SignerNotification__PermitsPerWindow, …New in 2.16.0. Budget for POST /api/signer/notifications, Lacuna Signer's webhook. Generous because its one legitimate caller delivers every event in the organization from a handful of addresses, so the per-IP partition is in practice Signer's; the secret, not this budget, keeps strangers out. A throttled delivery gets 429, which Signer retries like any non-2xx, and the folder sweep finds whatever a retry gives up on.

Rate-limited responses carry code = "rate-limited" in the error envelope.

Hosting​

KeyTypeDefaultEnv overrideNotes
Hosting:RequireHttpsboolfalseHosting__RequireHttpsGates the in-process HTTPS redirect. false (default) for service and Docker installs that terminate TLS at a reverse proxy. Surfaced in the ready-summary banner as https redirect = on/off.
Hosting:ForwardedHeaders:EnabledboolfalseHosting__ForwardedHeaders__EnabledRead the client's address and scheme from X-Forwarded-For and X-Forwarded-Proto. Off by default, and off is byte-for-byte the product as it shipped — no middleware is added at all. Turning it on requires a trust set: one of TrustAnyProxy, KnownProxies or KnownNetworks, or the boot is refused. Surfaced in the ready-summary banner as forwarded headers = …, naming the trust set rather than only on.
Hosting:ForwardedHeaders:TrustAnyProxyboolfalseHosting__ForwardedHeaders__TrustAnyProxyBelieve a forwarded header from any upstream address. The intended setting on Azure App Service, whose front door has no stable address to list. ⚠️ On a reverse-proxy deployment it means anybody who can reach Kestrel directly can name themselves any client address. Cannot be combined with the two keys below — a list beside it would be ignored, so it is refused at boot rather than resolved by precedence.
Hosting:ForwardedHeaders:KnownProxiesstring[][]Hosting__ForwardedHeaders__KnownProxies__0Bare IP addresses whose forwarded headers are believed (10.4.0.7, ::1). A value that is not an IP address is refused at boot, naming it. The framework's loopback defaults are not kept — when this section names a trust set, that set is the whole of it, so a proxy on this host must be listed.
Hosting:ForwardedHeaders:KnownNetworksstring[][]Hosting__ForwardedHeaders__KnownNetworks__0CIDR ranges whose forwarded headers are believed (10.4.0.0/16). Refused at boot if it is not a CIDR range, or if the address carries bits below its prefix length — 10.4.0.7/16 describes 10.4.0.0/16, and a file that says one range while the host trusts another is worth failing over.
Hosting:ForwardedHeaders:ForwardLimitint1Hosting__ForwardedHeaders__ForwardLimitHow many entries are taken off the right-hand end of each forwarded header — one per proxy the request genuinely passes through, which is 1 for both a platform load balancer and a single reverse proxy. Range [1, 16], refused at boot outside it; the framework's "unlimited" is deliberately not reachable, because an unlimited chain is one whose far end the client wrote.

Why forwarded headers are a product key and not just the framework's switch​

ASP.NET Core has its own switch, ASPNETCORE_FORWARDEDHEADERS_ENABLED=true, and it trusts any upstream with no way to narrow that. Setting both it and Hosting:ForwardedHeaders:Enabled is refused at boot, naming both: each adds forwarded-headers processing, so every header would be processed twice and a ForwardLimit of one would silently believe two hops.

Two things in the product act on the client address, which is why whose headers you believe is a configuration question rather than a detail:

  • The rate-limit partition. Behind a load balancer every caller arrives from one address, so the anonymous approval route's per-client budget becomes a single shared one for the whole world.
  • The address recorded on an approval, which is one of the compensating controls for the anonymous approval route. An address that is really the load balancer's records nothing about who decided.

On Azure App Service the intended setting is Enabled = true with TrustAnyProxy = true, and the hazard that carries is closed by locking the origin — see Azure App Service.

ApproverSecondFactor​

A TOTP second factor for approvers — a code from an authenticator app, held against a per-person enrolment. Off by default, so upgrading changes nothing. Read once at startup; change any of it and restart.

Host-wide rather than per signing profile, and that is the load-bearing choice rather than a convenience: a per-profile rule is frozen onto the job when it parks, and authentication must not be in that snapshot — the snapshot records which rule applied, so editing this file can never be an authorisation bypass. A host-wide switch has nothing per-profile to freeze, so a job that parked before the factor was enabled needs no migration and no special case.

KeyTypeDefaultEnv overrideNotes
ApproverSecondFactor:EnabledboolfalseApproverSecondFactor__EnabledMaster switch. When false nothing about any approver surface changes and no ApproverSecondFactor:* value is read. When true: the enrolment panel appears on /approvals, every decision on the portal asks for a code once per verification window, and the two surfaces that carry no browser session — POST /api/approvals/{id} and a decision from /approve/{jobId} — refuse outright. That last part breaks any ERP driving approvals over REST; see Approvals.
ApproverSecondFactor:SeedSecretstring—ApproverSecondFactor__SeedSecretREQUIRED when enabled. SECRET — never commit it. The key every approver's authenticator seed is encrypted at rest under (PBKDF2-HMAC-SHA256 → AES-256-GCM). Minimum 32 characters, the same floor ApproverPortal:LinkSecret carries. It encrypts the seeds; it does not derive them — seeds are random per approver, deliberately, so that holding the first factor cannot mint the second. Losing or rotating it means every approver enrols again.
ApproverSecondFactor:VerificationWindowTimeSpan00:20:00ApproverSecondFactor__VerificationWindowHow long a proven factor stands before it must be proven again. Zero is legitimate and means "prompt on every decision" — the strictest setting, not a disabled one. Absolute, never sliding, and scoped to one browser session. Bounds 00:00:00 to 08:00:00. Frozen onto each window when the code is entered, so editing it governs windows opened afterwards and changes nothing about ones already open.
ApproverSecondFactor:IssuerstringLacuna Bulk SignerApproverSecondFactor__IssuerThe label an authenticator app shows above the code. Carried in the provisioning URI's issuer parameter and repeated in its label, because apps disagree about which they read. Set it when one directory holds several environments and an approver would otherwise see two identically-named accounts.
Always write the days component in VerificationWindow

A three-component value is hh:mm:ss only while the first number is 23 or less; at 24 and above the binder reads it as days, so "24:00:00" means twenty-four days. Unlike ExpiresAfter, which accepts that silently, the eight-hour ceiling here turns it into a boot refusal that names the mistake.

Three boot refusals​

Enabled = true fails startup, naming the key, when:

  1. SeedSecret is missing or shorter than 32 characters. Enrolments are unreadable without it, so a half-configured factor is not a weaker control — it is one whose strength nobody has stated.
  2. VerificationWindow is negative or above eight hours. Beyond a shift the value stops describing presence at a keyboard and starts describing a session, which is what a sliding window was rejected for being.
  3. ApproverPortal:Enabled is false and no Auth:EntraId section is configured. Those are the only two surfaces that produce an identified session, and the factor is proven inside one. With neither, nobody could enrol, every job on an approval-configured profile would park indefinitely, and no payment file would be signed.

AllowedHosts​

KeyTypeDefaultEnv overrideNotes
AllowedHostsstring*AllowedHostsStandard ASP.NET Core host filtering. Override to a comma-separated list if the install is reverse-proxied with a fixed external host name.

Environment variables that have no JSON counterpart​

VariablePurpose
BULK_SIGNER_CONFIG_DIRTells the binary where the production config lives when the binary is in a read-only install location. Set by the install scripts.
BULK_SIGNER_PKCS11_PINThe HSM/token PIN — read at the env-var name configured by Signing:Certificate:Pkcs11:PinEnvVar.
BULK_SIGNER_ENCRYPTION_PASSWORDPBKDF2 password — read at the env-var name configured by Encryption:PasswordEnvVar.
APPLICATIONINSIGHTS_CONNECTION_STRINGStandard Azure Monitor variable. Read directly by the exporter and honoured by the startup validator, so Telemetry:ConnectionString can be left unset. See Telemetry.
ASPNETCORE_ENVIRONMENTStandard ASP.NET Core environment name (Development, Production). The install scripts set Production. Under Production, Signing:TrustLacunaTestRoot is refused at boot — a homologation host that trusts the test root runs under another name, such as Staging.
ASPNETCORE_URLSStandard. The install scripts set http://0.0.0.0:8080.
ASPNETCORE_CONTENTROOTStandard. The Windows install sets it to C:\ProgramData\Lacuna\BulkSigner so file-path resolution lands on operator-writable disk.

Verifying configuration at runtime​

The ready-summary banner printed on startup lists the most decision-critical settings (host mode, environment, https redirect, content root, storage root, license fingerprint, cert source, signing policy, encryption status, poll interval, pipeline mode, and one operational store row naming the database provider). A mistyped key surfaces there as a default value rather than the value you intended. Four more rows are always present and each answers one question: trust set (whether the Lacuna test root is trusted — see Signing:TrustLacunaTestRoot), web pki license and cloudhub (configured or not, never the value), and customer logo (none, the logo that loaded, or the reason it was not). The ready and metrics rows say (anonymous) or (API key), so a Readiness:RequireApiKey or Metrics:RequireApiKey that did not bind is visible at boot.

Rows that appear only when the matching feature is configured:

Banner rowAppears when
store status, store isolationDatabase:Provider = SqlServer
work share, azure credential, azure shares, input providers, work share ownerStorage:Provider = AzureFiles, or any input folder that names it
blob=… on a profile rowthat profile's certificate is read from Azure Blob Storage
cnab240=on, approval=N/M, expires=… on a profile rowCheckCNAB240 / Approval on that profile
DEGRADED · prefix on a profile row, with a FAIL line beside itthat profile's certificate did not open

/api/ready returns a JSON body naming each probe — operational store, per input folder, license, storage-share: and work-share-owner rows on a remote work share, and one row per degraded signing profile — with its verdict and no detail; /api/ready/details, with the API key, adds each probe's detail. A 503 with a body listing the failed probe is the fast feedback loop for config mistakes — see Troubleshooting.

Some rows report ok: false without making the response a 503, because a 503 pulls the instance out of its load balancer and each of these has a remedy that the instance itself serves: the Azure table log sink's (a logging outage must not become an ingestion outage), a degraded signing profile's (the dashboard is where it is fixed), a profile-input-folder: row for a profile bound to a folder this host does not configure, and, in cluster mode, a stood-down instance's cluster-instance row. Alerting should read checks[] rather than only the top-level ready.


Next: Certificates — choosing and configuring a certificate source. Previous: Installation.