Pular para o conteúdo principal

Installation

Lacuna Bulk Signer is a single service that can run as four supported targets:

TargetProcess modelLifecycle managed by
Linux systemdBackground servicesystemctl
Windows ServiceBackground serviceservices.msc / sc.exe
Docker / ComposeContainerdocker compose
Console (one-off / test)ForegroundOperator (Ctrl+C)

The same binary supports all four. The startup banner prints a host mode = … line that tells you which lifetime is actually active.

You download the application from Lacuna: the container image from Lacuna's private Docker image repository, or the published binaries from an OS-specific URL carrying your organization's unique identifier. The per-target install scripts and the annotated sample configuration arrive separately, in the deployment package. Obtaining the product covers both, and the credentials each one takes.

Choose your target

Where will the service run?Use
Linux serversystemd — deploy/linux/install.sh
Windows serverWindows Service — deploy/windows/Install-Service.ps1
Any host with DockerContainer — deploy/docker/docker-compose.yml
Azure, on more than one instanceAzure App Service (cluster mode) — its own page
Just testing locallyConsole — run the published executable in the foreground

Every target on this page is a single instance, which is what this product is unless you deliberately turn on cluster mode. Running two of these against one work share is a documented hazard, not a deployment — see Operations. The one supported multi-instance topology is an Azure Web App scaled out on one App Service Plan, and it has its own walkthrough (Azure App Service) and its own list of limits (High availability).

Obtaining the product

There is no public download, and nothing here is built from source. Lacuna Software ships the application two ways, and the target you just picked decides which one you take:

What you downloadFromTargets that take it
The container image, prebuiltLacuna's private Docker image repository, as <lacuna-registry>/bulksigner:<version>Docker / Compose, Azure App Service
The published binaries, one archive per OSA download URL carrying your organization's unique identifierLinux systemd, Windows Service, console

Neither artifact carries the install scripts. Those come in the deployment package — one archive, the same on every OS, holding the deploy/ tree, the annotated appsettings.Production.json.sample, and the PowerShell helper scripts documented in Samples. Unpack it on the machine you will install from: every deploy/… path on this page is relative to its root.

Lacuna issues you three things, and they are not interchangeable:

Issued to youWhat it unlocksIf it leaks or expires
Registry credentials — a username and an access tokenPulling the image from the private repositoryAsk Lacuna to reissue. The token is scoped to your organization and revocable on its own.
A unique identifierThe binary download URLsAsk Lacuna to reissue. It identifies your organization rather than a release — one identifier serves every OS.
The PKI SDK license stringThe running service, on every target — not the downloadNot a distribution credential at all; see Obtaining the PKI SDK license.
The identifier in a download URL is a credential

It is the only thing standing between that URL and anybody holding it, so a URL that carries it does not belong in a public issue, a shared CI log, a wiki page or a committed script. Keep it where you keep the registry token — and if it does get out, ask Lacuna to reissue rather than counting on the link staying obscure.

The container image

docker login <lacuna-registry> --username <registry-username> # prompts for the access token
docker pull <lacuna-registry>/bulksigner:<version>

Lacuna supplies the registry host, the repository path and the credentials together. The repository is private, so an unauthenticated pull answers not found rather than unauthorized — that is Docker's usual reply for a repository your credentials cannot see, and not a sign that you mistyped the name.

Pin <version>. A latest tag moves, and on a container host that means a restart can bring up a release you did not choose to install.

Nothing is built locally: the image Lacuna publishes is the image that runs, Debian-slim based for the reason in Docker / Compose below.

The published binaries

Take the archive matching the host's OS, and extract it where the install script can read it:

# Linux
curl -fL -o bulksigner-linux-x64.tar.gz \
"https://cdn.lacunasoftware.com/bulk-signer/<identifier>/linux-x64.tar.gz"
mkdir -p publish && tar -xzf bulksigner-linux-x64.tar.gz -C publish
# Windows — an ordinary prompt is enough here; only the install itself needs elevation
Invoke-WebRequest -Uri "https://cdn.lacunasoftware.com/bulk-signer/<identifier>/win-x64.zip" -OutFile bulksigner-win-x64.zip
Expand-Archive -Path bulksigner-win-x64.zip -DestinationPath publish

publish is the name the rest of this page uses, because it is what the install scripts take as --from publish / -From publish — the archive does not care, and the switch accepts any path. Ask Lacuna if you need an OS or an architecture those two URLs do not cover.

A host with no route to the internet

Neither artifact needs one at install time: both are whole, with no package feed, no restore step and no second call home, so a download on a connected workstation and a copy across is a complete answer. Bring the binaries over as the archive; bring the image over with docker save / docker load, or push it into a registry the host can reach — which is what step 1 of the Azure walkthrough does, for a different reason. The PKI SDK license is a string rather than a download, so an air-gapped install stays air-gapped.

Then check what you got

The version actually running is in the dashboard's app bar on every page, printed whole under the branded console banner at each start, and on the System page. Once the install below is done, read it against the release you were told to install — that is the only confirmation that the URL, or the tag, served what you expected.

Prerequisites — common to every target

  1. Lacuna PKI SDK license string (base64), supplied by Lacuna Software. Required at startup; without it, the service refuses to boot. See Obtaining the PKI SDK license.

  2. A signing certificate source. Pick one of:

    • PFX — a .pfx / .p12 file plus the password that unlocks it.
    • PKCS#11 — a vendor driver (.so on Linux, .dll on Windows) plus the SHA-1 thumbprint of the signing certificate on the token, plus the PIN supplied through an environment variable.
    • Windows certificate store — Windows targets only, plus the SHA-1 thumbprint.

    See Certificates for details.

  3. Encryption decision. Leave disabled (default) or enable BSENC v1. If you enable encryption, decide where the password and salt will live before first boot. See Encryption.

  4. TLS termination. The service listens on plain HTTP by default. The recommended deployment terminates TLS at a reverse proxy (nginx, IIS, Traefik). The Hosting:RequireHttps flag (default false) gates the in-process HTTPS redirect — set it to true only if you have configured a Kestrel certificate.

  5. Watched input folders. Decide whether you need one input folder (default) or several. With a single folder, omit Storage:Inputs[] entirely — the service creates one named default at {Root}/input. For multiple folders, populate Storage:Inputs[] with one entry per folder; see Configuration.

Every install seeds an editable production config from the provided appsettings.Production.json.sample. The sample is annotated with REQUIRED and SECRET markers; review it before first start.

Obtaining the PKI SDK license

The license is a base64 string supplied by Lacuna Software. Two ways to load it:

WhereHow
Environment variable (preferred)Set Signing__PkiSdkLicense=<base64-license>
Config fileSet Signing:PkiSdkLicense in appsettings.Production.json

The environment variable takes precedence at boot. The install scripts read the environment variable from the per-target file (/etc/bulksigner/bulksigner.env on Linux, machine-scope environment variables on Windows, .env on Docker) so the license never lands in a committed file. See Security for the full secrets-handling story.

Upgrading from 1.0.x

This key was named Signing:License (Signing__License) in 1.0.x and was renamed in 1.1.0. The old name is no longer read, so an upgraded install that still sets it fails at startup with Signing:PkiSdkLicense is required. Rename the key in your config file or environment file as part of the upgrade.

Linux — systemd

# 1. With the binaries extracted to publish/ and the deployment package unpacked on the
# target machine (see Obtaining the product above):
sudo bash deploy/linux/install.sh --from publish

# 2. Edit the production config and the secrets env file.
sudo nano /etc/bulksigner/appsettings.Production.json
sudo nano /etc/bulksigner/bulksigner.env

# 3. Restart so config changes take effect.
sudo systemctl restart bulksigner

# 4. Verify the service is up.
curl http://localhost:8080/api/health
curl http://localhost:8080/api/ready
systemctl --no-pager status bulksigner
journalctl -u bulksigner -f

Install paths (FHS conventions):

PathPurposeModeOwner
/opt/bulksignerBinary (read-only after install)0755root:root
/var/lib/bulksignerData: input / processing / output / db0750bulksigner:bulksigner
/var/log/bulksignerDurable log files0750bulksigner:bulksigner
/etc/bulksignerappsettings.Production.json + bulksigner.env0750bulksigner:bulksigner

The systemd unit uses Type=notify so systemctl status reports active (running) only after the full bootstrap (license load + database migration + pipeline recovery) succeeds. Hardening flags (NoNewPrivileges, ProtectSystem=strict, PrivateTmp) are on by default.

Uninstall:

sudo bash deploy/linux/uninstall.sh # stop + remove the unit, preserve data
sudo bash deploy/linux/uninstall.sh --purge # also wipe data, logs, config, and the system user

Windows — Windows Service

# 1. With the binaries extracted to publish\ and the deployment package unpacked on the
# target machine (see Obtaining the product above), in an ELEVATED PowerShell prompt:
.\deploy\windows\Install-Service.ps1 -From publish

# 2. Edit the production config:
notepad C:\ProgramData\Lacuna\BulkSigner\config\appsettings.Production.json

# 3. Set secrets as machine-scope environment variables:
[Environment]::SetEnvironmentVariable("Signing__PkiSdkLicense", "<base64-license>", "Machine")
[Environment]::SetEnvironmentVariable("Auth__ApiKey", "<api-key>", "Machine")
[Environment]::SetEnvironmentVariable("BULK_SIGNER_PKCS11_PIN", "<hsm-pin>", "Machine")
[Environment]::SetEnvironmentVariable("BULK_SIGNER_ENCRYPTION_PASSWORD", "<password>", "Machine")
Restart-Service LacunaBulkSigner

# 4. Verify.
Invoke-WebRequest http://localhost:8080/api/health
Invoke-WebRequest http://localhost:8080/api/ready
Get-Service LacunaBulkSigner
Get-Content C:\ProgramData\Lacuna\BulkSigner\logs\bulksigner-*.log -Tail 50 -Wait

Install paths (Windows conventions):

PathPurpose
C:\Program Files\Lacuna\BulkSignerBinary (read-only after install)
C:\ProgramData\Lacuna\BulkSigner\configappsettings.Production.json
C:\ProgramData\Lacuna\BulkSigner\dataOperational data (input / processing / output / db)
C:\ProgramData\Lacuna\BulkSigner\logsLog files

The service runs under a virtual account (NT SERVICE\LacunaBulkSigner) — no operator password to manage, no domain account to permission. The install script grants this account access to the ProgramData tree and configures crash recovery (restart after 5 s on the first and second failure, 30 s on the third).

note

Application-level logs go through the file sink only. The Windows Application event log carries service lifecycle entries (start / stop / failure) for this service — not the per-job log lines. Look in the log file for those.

Uninstall:

.\deploy\windows\Uninstall-Service.ps1 # stop + remove the service, preserve data
.\deploy\windows\Uninstall-Service.ps1 -Purge # also wipe ProgramData and the machine env vars

Docker / Compose

cd deploy/docker

# 1. Authenticate to Lacuna's private registry — the compose file's image: line names it.
docker login <lacuna-registry> --username <registry-username>

# 2. Prepare working directories on the host.
cp .env.sample .env
mkdir -p data logs config
cp ../appsettings.Production.json.sample config/appsettings.Production.json

# 3. Edit the config and the env file.
nano config/appsettings.Production.json
nano .env

# 4. The container runs as UID 1654. On Linux hosts:
sudo chown -R 1654:1654 data logs

# 5. Start — `up` pulls the image on its first run.
docker compose up -d

# 6. Verify.
curl http://localhost:8080/api/health
docker compose ps # should show "healthy" after ~30 s
docker compose logs -f bulksigner

The compose file's image: line is where the private repository and its pinned tag live, so it is the line you edit at upgrade time. A pull that fails looks like a container that never starts with an empty application log — because there is no application yet — so check docker login before reading anything into the silence.

Lacuna builds the image on Debian-slim — not Alpine. HSM .so libraries are generally not musl-compatible, so Alpine is off the table. The image ships generic PKCS#11 tooling (libpcsclite1 + opensc); vendor HSM drivers (SafeNet, Thales, Entrust, Yubico) are operator-mounted at runtime via volumes: in the compose file. See the commented examples in deploy/docker/docker-compose.yml.

A HEALTHCHECK polls /api/health every 30 seconds, so docker ps and orchestrators see accurate (healthy) / (unhealthy) status.

Bind mounts and host paths:

Container pathHost pathPurpose
/app/appsettings.Production.json./config/appsettings.Production.json (read-only)Operator-edited config
/var/lib/bulksigner./dataOperational data tree (input / processing / output / db)
/var/log/bulksigner./logsDurable log files

Foreground console (one-off / test)

Run the published executable directly to start the service in the foreground — useful for a quick local test or to see bootstrap errors immediately:

# Linux
./publish/Lacuna.BulkSigner

# Windows
.\publish\Lacuna.BulkSigner.exe
  • The data/ tree is created relative to the working directory.
  • Use Ctrl+C to stop. The bootstrap banner prints host mode = console.
  • On an interactive terminal, a live status panel replaces the streaming log. See Console dashboard.

Azure Files storage (optional)

Every install above keeps the whole data/ tree on the host. Setting Storage:Provider = AzureFiles moves the work shareprocessing/, output/ and error/ — into an Azure Files share reached through the service's own SDK. No SMB mount, no host-level dependency, no change to any install step above.

What you decide before installing:

DecisionNotes
Which share holds the work rootsOne share, not several: promoting a verified artifact and relocating a failed job's staged copy are renames, and Azure's rename cannot cross shares or accounts. Add a Directory prefix if several deployments share one share.
Where the input folders liveIndependent of the above and per folder — staging from an input folder is a copy, and a copy may cross anything. A folder can stay local while the work share is remote, or read a customer's share in their own account.
The credentialManagedIdentity (system-assigned, no secret) where the host runs in Azure; ServicePrincipal on-premises; AccountKey only where the host cannot reach the tenant at all. See Security.
The poll interval per remote folderAzure Files publishes no change notifications, so a remote folder is enumerated on a timer. A folder that resolves to no interval is refused at boot.

Host requirements are the same on every target: outbound HTTPS to https://<account>.file.core.windows.net, and — for ManagedIdentity — a reachable IMDS endpoint, which on the Docker target means the container can reach 169.254.169.254.

logs/ and the SQLite database can never move to a share

Logging:File:Path is always a local path, and startup refuses a configuration that moves it: the file sink reaches the filesystem through its own API and cannot be handed a share. Under Database:Provider = Sqlite, ConnectionStrings:Default is refused on a share for the reason a database file reached over SMB is the documented way to corrupt one. Under SqlServer the db/ directory is simply unused.

What changes operationally. Ingestion moves from event-driven to a timer (about half a minute on the defaults, worst case), inspecting error/ and processing/ means a storage client rather than an SSH session, and the share is marked at boot so a second instance is visible. All three are covered in Operations.

Verifying. The boot banner gains work share, azure credential, input providers and azure shares = N reachable rows; /api/ready gains one storage-share:<account>/<share> check per share plus a work-share-owner row. Drop a file in a remote input folder and watch it appear as a job within one poll interval.

The certificate file in Azure Blob Storage (optional)

A host with no durable local disk also has nowhere to keep the .pfx or .cer. Both sources that name a file can name a blob instead — see Certificates. Two things to plan for at install time:

  • The identity needs Storage Blob Data Reader on the container, and nothing wider.
  • An unreachable blob stops the host from starting, unlike an unreachable work share or operational store. A profile with no signing material cannot sign at all, so there is no useful degraded state.

Choosing where the operational store lives

Every install above put the operational store — jobs, their history, operational events, the pipeline's pause flag, the frozen approval rules and the recorded approvals — in a SQLite file under Storage:Root. That is the default, it stays the default, and a deployment that says nothing keeps it.

ChooseWhen
Sqlite (default)Anything with durable local disk. No external dependency, nothing to provision, and correct for an air-gapped install.
SqlServerYour policy puts operational data in your own DBMS, under your own backup, HA and DR regime — the recorded approvals in particular are the evidence an auditor asks for. Or the host has no durable local disk, where a SQLite file is not a record but a record that disappears on the next revision.
Neither is a throughput decision

SQLite is not this pipeline's ceiling — the bound is cryptography and I/O — and nothing about SqlServer makes signing faster. It matters because "we moved to SQL Server" reads as a scaling story, and that leads to the wrong inference about instances: choosing SqlServer does not by itself make a second instance supported. Running more than one is an explicit opt-in (Cluster:Enabled) with its own prerequisites and its own list of limits — SqlServer is one of those prerequisites rather than the whole of it. Off that switch, two instances over one work share remain a documented hazard: see Operations. To actually scale out, start at High availability and its limits.

What to have in place before the first boot

  1. The database, created. 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 — the service starts, and /api/ready stays red.

  2. A login mapped to a user in it, in db_datareader + db_datawriter + db_ddladmin — not db_owner. The ALTER ROLE script is in Configuration.

  3. Network reach and TLS the host accepts. The SQL client encrypts by default, so an on-premises server whose certificate the host does not trust refuses the login with a certificate chain … not trusted error.

    Azure SQL additionally needs a server firewall rule (or a private endpoint / VNet rule) for this host, plus the outbound ports its connection policy implies — the prerequisite most easily missed, because the default policy is not one thing:

    Where the host runsDefault policyOutbound ports to allow
    Inside Azure (VM, VMSS, container app, App Service)RedirectTCP 1433 to the gateway and TCP 11000–11999 to the region's SQL addresses. Use the Sql.<region> service tag on an NSG rather than enumerating IPs.
    Outside Azure (on-premises host reaching Azure SQL)ProxyTCP 1433 only.

    Allowing 1433 alone from inside Azure is enough to establish the TCP session and not enough to use it, which is exactly the shape of failure this list exists to prevent.

Per target — where the connection string lives

The connection string may be the whole of the credential, so it belongs wherever that target already keeps secrets — the same places, and for the same reasons, as the PKI license.

TargetWhere to put ConnectionStrings__DefaultPasswordless option
Linux systemd/etc/bulksigner/bulksigner.env (0640, owned by bulksigner). Or appsettings.Production.json when it carries no password.On an Azure VM or VMSS: Authentication=Active Directory Managed Identity. Not on-premises — use a SQL login there.
Windows ServiceMachine-scope environment variable, set the same way as the PKI license.Windows integrated authentication. The service runs as NT SERVICE\LacunaBulkSigner, which reaches the network as the computer account, so the login to create is DOMAIN\HOSTNAME$. Run the service as a gMSA or a domain user for a per-service identity.
Docker / Composedeploy/docker/.env.Only where the container can reach the host's IMDS endpoint (169.254.169.254). Unlike the Azure Files provider, a user-assigned identity also works here (User Id=<client-id>).
Console (dev)appsettings.Development.json or an ordinary shell environment variable.Authentication=Active Directory Default picks up your own az login — convenient locally, and not what you want in production.

Switching from SQLite — archive the old file first

Setting Database:Provider = SqlServer on an existing install comes up against an empty store. There is no importer and no boot-time check for the file left behind: no jobs, no history, no operational events, and no approval snapshots and no recorded approvals.

In order:

  1. Drain the pipeline — pause it, let the in-flight count reach zero, then stop the service. Release or reject any job parked at the approval gate first: in the new store it does not exist, and its approvers' decisions are in the file you are about to archive.
  2. Copy db/bulksigner.db somewhere your retention policy covers, and keep a SQLite client to hand. From this point it is your archive, not the service's.
  3. Create the database and the login, set Database:Provider and ConnectionStrings:Default, start the service, and verify below.

Files in input/, output/ and error/ are untouched by the switch — but startup recovery reconciles processing/ from job rows, and the new store has none. Inspect and clear any leftovers by hand, against the archived database, before the first boot on the new store.

Verifying a SQL Server store

Check the boot banner, which names the store on every deployment:

operational store = SQL Server (sqlsrv01/BulkSigner)
store status = reachable

A local install reads operational store = SQLite (data/db/bulksigner.db) and gets neither of the other two rows — nothing was probed. Neither row ever carries the connection string.

Two rows are the ones to act on:

  • store status = UNREACHABLE: … — the store did not answer. The host started anyway on purpose (a database down during a maintenance window must not turn a restart into an outage), the migration was skipped, and /api/ready is red. Fix the store and restart.
  • store isolation = READ_COMMITTED_SNAPSHOT off … — the one setting that makes the product feel broken without failing anything: the dashboard's reads will block behind the pipeline's writes. Azure SQL enables it by default, on-premises SQL Server does not. Bulk Signer reports it and never changes it — the statement needs exclusive access to a database that is yours. One ALTER DATABASE by a DBA, then restart.

Then curl http://localhost:8080/api/ready — its database check names the store it actually checked.

Microsoft Entra ID sign-in (optional)

By default the dashboard sign-in is the API key, and nothing here is needed — an air-gapped deployment never touches a Microsoft tenant. To let people sign in with their organization's Microsoft Entra ID accounts instead:

Scripted alternative

New-BulkSignerEntraApp.ps1 (shipped with the deployment package, see Samples) performs steps 1, 2 and 4 through Microsoft Graph, creates the client secret, and prints the exact configuration block for step 5. Only step 3 stays manual.

pwsh New-BulkSignerEntraApp.ps1 -BaseUrl https://signer.example.com

1. Register the application in the tenant (Entra admin center → App registrations → New):

  • Supported account types: Accounts in this organizational directory only — single tenant; the app refuses the multi-tenant pseudo-tenants at boot.
  • Redirect URI: type Web, value https://<your-host>/signin-oidc.
  • Under Certificates & secrets, create a client secret and copy its value immediately — it is shown once.

2. Create the two app roles (App registration → App roles → Create):

Display nameValue (must match exactly)Allowed member typesGrants
AdministratorAdministratorUsers/GroupsThe operator dashboard — every page and action the API-key cookie grants today.
ApproverApproverUsers/GroupsThe approver surfaces. Which payment files the person may decide is still governed by the frozen approver pool, matched by email — the role only opens the door.

3. Assign people (Enterprise application → Users and groups → Add). One person may hold both roles and is genuinely both. An account with neither role is refused by the app even when it authenticates.

4. Require assignment (Enterprise application → Properties → Assignment required = Yes), so unassigned accounts fail at Microsoft's door. The app enforces role presence regardless — tenant configuration alone must never be the only fence.

5. Configure the host — see Configuration:

Auth__EntraId__TenantId=<directory (tenant) GUID or verified domain>
Auth__EntraId__ClientId=<application (client) id>
Auth__EntraId__ClientSecret=<the secret from step 1> # env var recommended; never commit

Restart. /login now offers Sign in with Microsoft, and the API-key form is off — a half-filled section refuses to boot naming the missing key. Approvers who match a pool by their directory email land on /approvals; the durable approver links keep working for people outside the tenant.

Email matters for approvers

Pool matching binds on the token's email claim. For guest accounts — external approvers invited into the tenant — make sure the account's mail attribute carries the business address configured in the pool. The mangled #EXT# UPN is deliberately not used as a fallback.

Turning this on signs everyone out

Existing operator cookies stop satisfying policies immediately — there is no eight-hour tail of sessions minted by a login form that no longer exists. Plan the cutover accordingly. REST clients using X-API-Key are unaffected.

Upgrades

The database schema migrates automatically at startup. Fetch the new release first — the same two channels as the first install, Obtaining the product — and use the deployment package that came with it rather than the copy you installed from last time. Then, to upgrade in place:

TargetSteps
LinuxExtract the new binaries archive, then sudo bash deploy/linux/install.sh --from <new-publish-dir> — stops the unit, redeploys the binary, restarts.
WindowsExtract the new binaries archive, then .\deploy\windows\Install-Service.ps1 -From <new-publish-dir> — stops the service, mirrors the binary tree, restarts.
DockerBump the tag on the compose file's image: line, then docker compose pull && docker compose up -d. Run docker login again first if the access token has expired since the install.
Always back up the operational database before upgrading.

Under Database:Provider = Sqlite:

TargetBackup command
Linuxsudo cp /var/lib/bulksigner/db/bulksigner.db /var/lib/bulksigner/db/bulksigner.db.bak
WindowsCopy-Item C:\ProgramData\Lacuna\BulkSigner\data\db\bulksigner.db -Dest .\bulksigner.db.bak
Dockercp deploy/docker/data/db/bulksigner.db deploy/docker/data/db/bulksigner.db.bak

Under SqlServer, backup is your DBMS regime's concern — which is one of the two reasons a customer chooses that provider. db_ddladmin must be in place for the boot that applies the migration; a boot against a schema that is already current creates nothing.

The startup recovery sweep moves any job left in flight by the previous version aside automatically — no manual cleanup needed. See Operations.

Upgrading to 2.0.0

Four changes can stop a deployment that starts today, or change what an existing script sees. Each is deliberate; the first is the one to check before editing anything.

  • A watched input folder pointed at a work root now refuses the boot. Such a configuration was re-ingesting and then deleting the artifacts it produced, one per iteration, while reporting every job Completed. Check output/ against what recipients actually collected before you fix the config — see Troubleshooting.
  • Clear Jobs deletes finished records only. DELETE /api/jobs now reports skipped alongside deleted, and a script that clears the table and then expects it empty has to drain or cancel the unfinished jobs first. See Operations.
  • A file whose path exceeds 850 characters is refused when it is taken in, with the new problem code job.path-too-long, rather than being accepted and failed later.
  • The dashboard's "Max throughput/sec" card is retired. The bulksigner_signing_duration_seconds histogram on /api/metrics is unchanged and still the external record. See Job statistics.

This release adds migrations in both database histories, applied at boot — so the backup warning above matters more than usual on this upgrade.

Turning on cluster mode? Boot once with it off first

Cluster:Enabled = true filters startup recovery to each instance's own jobs, and a row left in progress by an older build carries no owner — so nothing under the switch will ever sweep it. Boot once with Cluster:Enabled = false, let recovery run, then turn the mode on. It is a one-time concern at the upgrade. See Azure App Service.

Quick health checks

After installing on any target:

URLWhat it tells you
http://localhost:8080/api/healthLiveness — anonymous, returns 200 OK if the host process is up.
http://localhost:8080/api/readyReadiness — anonymous, returns a body listing each probe (operational store, per input folder, license, plus storage-share: and work-share-owner rows on a remote work share). 503 if any probe failed.
http://localhost:8080/The operator dashboard. Sign in with the API key from Auth:ApiKey — or with Microsoft, when Entra ID sign-in is configured.
http://localhost:8080/scalar/v1The live OpenAPI reference UI for the REST surface.

/api/health is always anonymous so external health checkers do not need credentials. /api/ready is anonymous too and returns a structured body. /api/metrics is API-key-gated by default — see Security.


Next: Configuration — what every appsettings.json key does.