Installation
Lacuna Bulk Signer is a single service that can run as four supported targets:
| Target | Process model | Lifecycle managed by |
|---|---|---|
| Linux systemd | Background service | systemctl |
| Windows Service | Background service | services.msc / sc.exe |
| Docker / Compose | Container | docker compose |
| Console (one-off / test) | Foreground | Operator (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 server | systemd — deploy/linux/install.sh |
| Windows server | Windows Service — deploy/windows/Install-Service.ps1 |
| Any host with Docker | Container — deploy/docker/docker-compose.yml |
| Azure, on more than one instance | Azure App Service (cluster mode) — its own page |
| Just testing locally | Console — 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 download | From | Targets that take it |
|---|---|---|
| The container image, prebuilt | Lacuna's private Docker image repository, as <lacuna-registry>/bulksigner:<version> | Docker / Compose, Azure App Service |
| The published binaries, one archive per OS | A download URL carrying your organization's unique identifier | Linux 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 you | What it unlocks | If it leaks or expires |
|---|---|---|
| Registry credentials — a username and an access token | Pulling the image from the private repository | Ask Lacuna to reissue. The token is scoped to your organization and revocable on its own. |
| A unique identifier | The binary download URLs | Ask Lacuna to reissue. It identifies your organization rather than a release — one identifier serves every OS. |
| The PKI SDK license string | The running service, on every target — not the download | Not a distribution credential at all; see Obtaining the PKI SDK license. |
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
-
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.
-
A signing certificate source. Pick one of:
- PFX — a
.pfx/.p12file plus the password that unlocks it. - PKCS#11 — a vendor driver (
.soon Linux,.dllon 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.
- PFX — a
-
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.
-
TLS termination. The service listens on plain HTTP by default. The recommended deployment terminates TLS at a reverse proxy (nginx, IIS, Traefik). The
Hosting:RequireHttpsflag (defaultfalse) gates the in-process HTTPS redirect — set it totrueonly if you have configured a Kestrel certificate. -
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 nameddefaultat{Root}/input. For multiple folders, populateStorage: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:
| Where | How |
|---|---|
| Environment variable (preferred) | Set Signing__PkiSdkLicense=<base64-license> |
| Config file | Set 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.
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):
| Path | Purpose | Mode | Owner |
|---|---|---|---|
/opt/bulksigner | Binary (read-only after install) | 0755 | root:root |
/var/lib/bulksigner | Data: input / processing / output / db | 0750 | bulksigner:bulksigner |
/var/log/bulksigner | Durable log files | 0750 | bulksigner:bulksigner |
/etc/bulksigner | appsettings.Production.json + bulksigner.env | 0750 | bulksigner: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):
| Path | Purpose |
|---|---|
C:\Program Files\Lacuna\BulkSigner | Binary (read-only after install) |
C:\ProgramData\Lacuna\BulkSigner\config | appsettings.Production.json |
C:\ProgramData\Lacuna\BulkSigner\data | Operational data (input / processing / output / db) |
C:\ProgramData\Lacuna\BulkSigner\logs | Log 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).
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 path | Host path | Purpose |
|---|---|---|
/app/appsettings.Production.json | ./config/appsettings.Production.json (read-only) | Operator-edited config |
/var/lib/bulksigner | ./data | Operational data tree (input / processing / output / db) |
/var/log/bulksigner | ./logs | Durable 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+Cto stop. The bootstrap banner printshost 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:
| Decision | Notes |
|---|---|
| Which share holds the work roots | One 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 live | Independent 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 credential | ManagedIdentity (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 folder | Azure 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 shareLogging: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.
| Choose | When |
|---|---|
Sqlite (default) | Anything with durable local disk. No external dependency, nothing to provision, and correct for an air-gapped install. |
SqlServer | Your 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. |
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
-
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/readystays red. -
A login mapped to a user in it, in
db_datareader+db_datawriter+db_ddladmin— notdb_owner. TheALTER ROLEscript is in Configuration. -
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 runs Default policy Outbound 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.
| Target | Where to put ConnectionStrings__Default | Passwordless 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 Service | Machine-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 / Compose | deploy/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:
- 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.
- Copy
db/bulksigner.dbsomewhere your retention policy covers, and keep a SQLite client to hand. From this point it is your archive, not the service's. - Create the database and the login, set
Database:ProviderandConnectionStrings: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/readyis 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. OneALTER DATABASEby 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:
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 name | Value (must match exactly) | Allowed member types | Grants |
|---|---|---|---|
| Administrator | Administrator | Users/Groups | The operator dashboard — every page and action the API-key cookie grants today. |
| Approver | Approver | Users/Groups | The 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.
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.
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:
| Target | Steps |
|---|---|
| Linux | Extract the new binaries archive, then sudo bash deploy/linux/install.sh --from <new-publish-dir> — stops the unit, redeploys the binary, restarts. |
| Windows | Extract the new binaries archive, then .\deploy\windows\Install-Service.ps1 -From <new-publish-dir> — stops the service, mirrors the binary tree, restarts. |
| Docker | Bump 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. |
Under Database:Provider = Sqlite:
| Target | Backup command |
|---|---|
| Linux | sudo cp /var/lib/bulksigner/db/bulksigner.db /var/lib/bulksigner/db/bulksigner.db.bak |
| Windows | Copy-Item C:\ProgramData\Lacuna\BulkSigner\data\db\bulksigner.db -Dest .\bulksigner.db.bak |
| Docker | cp 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. Checkoutput/against what recipients actually collected before you fix the config — see Troubleshooting. - Clear Jobs deletes finished records only.
DELETE /api/jobsnow reportsskippedalongsidedeleted, 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_secondshistogram on/api/metricsis 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.
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:
| URL | What it tells you |
|---|---|
http://localhost:8080/api/health | Liveness — anonymous, returns 200 OK if the host process is up. |
http://localhost:8080/api/ready | Readiness — 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/v1 | The 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.