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, based on Ubuntu 24.04 LTS 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. The certificate you configure seeds the first signing profile on the first boot; from then on profiles — their certificate included — live in the operational store and are managed from the dashboard (see Configuration). If a profile's certificate carries a secret — a PFX password, an Azure Key Vault application secret, a blob credential — also set Signing:ProfileSecretsKey before the first boot, and back it up: the import refuses without it, and losing it later means re-entering those secrets.

  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. Which signing profile each folder's files are signed under is chosen on the profile's page after the first boot, one folder per profile; a folder's Profile key only seeds that choice on the first boot (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 Ubuntu 24.04 LTS — not Alpine. HSM .so libraries are generally not musl-compatible, so Alpine is off the table; the requirement is glibc, and .NET 10 publishes no Debian image, which is why this is Ubuntu rather than the Debian-slim base earlier versions used. Nothing you configure changes with it: the same package names, the same non-root UID 1654, the same paths. 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

A customer logo for the sign-in and approver pages is mounted the same way when you use one — read-only, with Branding:CustomerLogo:Path naming the path inside the container. The compose file carries the commented example.

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 share — processing/, 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 leaves that profile degraded, and the host running. It is named on the startup banner, in the log and on /api/ready, jobs routed to it fail with profile.degraded, and every other profile keeps signing. Fix the access and restart. (Up to 2.0.x an unreachable blob stopped the host from starting.)

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 -H "X-API-Key: …" http://localhost:8080/api/ready/details — its database check names the store it actually checked. (/api/ready alone tells you the check is green; the name of the store is on the details route, behind the key.)

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. Reversed in 2.9.0 — see below.
  • 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.

Upgrading within 2.x​

Every 2.x release upgrades in place with the steps above, and you can go from any 2.x straight to the latest — the migrations of the releases in between are applied in order at the first boot. Releases 2.1.0, 2.2.0, 2.4.3, 2.5.0, 2.7.0, 2.13.0, 2.14.0, 2.15.0 and 2.16.0 add migrations; take the backup above before any of them. The releases below also ask something of you, and are listed in the order you would cross them:

  • 2.1.0 — signing profiles move into the operational store. The first boot on 2.1.0 or later imports your Signing:Profiles[] section (or derives a default profile from Signing:Certificate) once; after that the section is ignored, the startup log says so on every boot until you remove it, and profiles are created and edited from the dashboard. If any profile carries a secret — a PFX password, a Key Vault application secret, a blob credential — set Signing__ProfileSecretsKey before that boot, or the import refuses, naming the key; back the key up with your other secrets. See Configuration. Three more changes land in the same release:
    • A half-written Signer: block now refuses the boot even when no profile uses Lacuna Signer — complete it or remove it.
    • A certificate that will not open no longer stops the host: that profile is degraded, its row on /api/ready reports ok: false without turning the response into a 503, and its jobs fail with profile.degraded. Alerting that reads only the top-level ready will not see it — read checks[].
    • A file an approver rejects is returned to output/ with .reject in its name, and its original is removed from the watched folder. Anything that treats every file in output/ as a signature must now read the name. If you turned the approver second factor on by setting only ApproverSecondFactor:SeedSecret, also set ApproverSecondFactor__Enabled=true explicitly — the shipped default is off.
  • 2.2.0 — the profile chooses its watched folder. The folder binding moves from Storage:Inputs[].Profile onto the profile. Upgrading from 2.0.x, the first boot's import binds each folder from that key, as before. Upgrading from 2.1.x, the profiles were already imported with no binding, so every watched folder comes up unassigned and its files wait, unsigned: choose each folder from its profile's page (Edit behaviour → Input folder) right after the upgrade.
  • 2.2.1 — sign out and back in once. The dashboard now carries the signed-in operator's identity into every page, and a session open across the upgrade keeps its old ticket until it is renewed. Under Entra ID, operators' audit events are now recorded under their UPN instead of (anonymous).
  • 2.3.1 — Auth:ApiKey must be your own. The shipped appsettings.json no longer carries a placeholder key, sample profiles or a Signer block, and its Pipeline:MaxConcurrency is back to the product default of 1. A deployment that never set Auth__ApiKey now refuses to start, naming the key; one that relied on the old concurrency sets Pipeline__MaxConcurrency itself. A first boot that refused naming profiles you never declared was this defect — use 2.3.1 or later.
  • 2.4.1 — Docker: skip the 2.3.2 and 2.4.0 images. Those two container images lacked the dashboard's client script: the pages rendered but no control did anything, while /api/ready stayed green. Pull 2.4.1 or later. Service installs were never affected.
  • 2.4.3 — pause and resume on SQL Server. A SQL Server store created before this release never got the row that holds the pause flag, so pausing answered pipeline.state-missing; the migration adds it.
  • 2.5.0 — cluster redeploys on App Service. A new container now displaces the one it replaces instead of being refused. Upgrades stay stop-the-world; see Azure App Service.
  • 2.6.0 — /api/ready loses its detail. The anonymous probe now carries only each check's name and verdict; a monitor that parsed detail moves to /api/ready/details and sends X-API-Key. The status code is unchanged. See Configuration.
  • 2.7.0 — one metric changes identity. bulksigner_approver_signatures_total gains a means label (browser / cloud), so its series identity changes for anything that scrapes it.
  • 2.9.0 and 2.10.0 — Clear Jobs takes everything. It now deletes every job whatever its status, the files those jobs left behind and (since 2.10.0) every operational event recorded before the clear, writing one JobsCleared event as the record of it. Its response drops skipped and gains filesDeleted, foldersDeleted, itemsFailed and eventsDeleted. A script written against the 2.0.0 behaviour above has to change. See Operations.
  • 2.13.0 — an already-processed file name is refused. A file arriving under a name a completed or still-active job carries now fails as file.already-processed instead of being signed again. A producer that reuses one fixed file name every day needs Pipeline__RejectAlreadyProcessedFileNames=false before the upgrade. See Configuration.
  • 2.16.0 — a Lacuna Signer outage now spends the poll budget. Nothing changes for a host with no Method = LacunaSigner profile. With one, a 5xx, 408 or 429 from Signer, a proxy's error page or a timeout during a poll or download now counts against Signer:MaxConsecutiveApiFailures — it was retried uncounted until Signer:TimeoutHours before — so an outage longer than the budget fails the waiting jobs signer.unreachable, to be retried once Signer is back. A job's originalPath may now be null on GET /api/jobs (only for the new SignerFolder method's jobs, which have no file); a client that assumes a string should tolerate it. See API failures.

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 by default, returns a body naming each probe (operational store, per input folder, license, plus storage-share: and work-share-owner rows on a remote work share) with its verdict and no detail. 503 if any gating probe failed.
http://localhost:8080/api/ready/detailsThe same report with each probe's detail. Needs the API key.
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 by default for the same reason and carries no detail; Readiness:RequireApiKey gates it where the prober can send a header. /api/ready/details and /api/metrics are API-key-gated — see Security.


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