---
title: "Installation"
description: "Installing Bulk Signer on its four supported targets (Linux systemd, Windows Service, Docker Compose and console) and how to obtain the application."
canonical: "https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation"
locale: "en"
product: "Bulk Signer"
last_updated: "2026-10-12"
---

# 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#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)](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure)** — 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#when-another-instance-appears-to-own-the-work-share). 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure)) and its own list of limits
([High availability](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure) |
| 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#obtaining-the-pki-sdk-license). |

> **Warning: 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

```bash
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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#docker--compose) below.

### The published binaries

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

```bash
# 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
```

```powershell
# 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure#1-import-the-image) 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signingprofiles--per-folder-signing-profiles)). 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#storage). 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#storageinputsprofile--per-folder-routing)).

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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/security) for the full secrets-handling story.

> **Warning: 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

```bash
# 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:**

```bash
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

```powershell
# 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).

> **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:**

```powershell
.\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

```bash
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 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 |

A [customer logo](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#branding--the-customers-logo-on-the-sign-in-and-approver-pages) 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:

```bash
# 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/dashboard#console-dashboard-foreground-runs-only).

## Azure Files storage (optional)

Every install above keeps the whole `data/` tree on the host. Setting
[`Storage:Provider = AzureFiles`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#storageprovider--storageazurefiles--the-work-share)
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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/security#azure-files-storage-credentials). |
| **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`.

> **Warning: 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#what-changes-day-to-day-on-a-share).

**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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/certificates#reading-the-file-from-a-blob). 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.

| 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. |

> **Info: 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#when-another-instance-appears-to-own-the-work-share). To actually scale out,
> start at [High availability and its limits](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/high-availability).

### 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#before-you-point-it-at-sql-server).
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 runs | Default policy | Outbound ports to allow |
   |---------------------|----------------|-------------------------|
   | **Inside Azure** (VM, VMSS, container app, App Service) | `Redirect` | TCP **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) | `Proxy` | TCP **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:

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:

```text
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:

> **Tip: Scripted alternative**
>
> `New-BulkSignerEntraApp.ps1` (shipped with the deployment package, see [Samples](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/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.
>
> ```bash
> 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#authentraid--optional-microsoft-entra-id-sign-in):

```bash
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.

> **Warning: 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.

> **Danger: 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#the-published-binaries), then `sudo bash deploy/linux/install.sh --from <new-publish-dir>` — stops the unit, redeploys the binary, restarts. |
| Windows | Extract the new [binaries archive](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#the-published-binaries), 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. |

> **Warning: Always back up the operational database before upgrading.**
>
> 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#startup-recovery).

### 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-deployment-that-used-to-start-now-refuses-naming-a-watched-input-folder).
- **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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#clear-jobs). *Reversed in 2.9.0 — see
  [below](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#upgrading-within-2x).*
- **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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/statistics#max-throughputsec-is-gone).

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

> **Warning: 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure#6-first-boot-on-one-instance).

### 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, 2.16.0, 2.17.0 and 2.18.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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signingprofilesecretskey--what-stored-profile-secrets-are-encrypted-under).
  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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure#8-upgrades-are-stop-the-world).
- **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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#readiness).
- **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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/operations#clear-jobs).
- **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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#pipeline).
- **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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#api-failures-and-the-per-job-budget).

## 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 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/details` | The 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#microsoft-entra-id-sign-in-optional) 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 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](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/security).

---

**Next:** [Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration) — what every `appsettings.json` key does.
