Server installation
The TrustBridge Server is distributed as a Linux container image, trustbridge-server:<version>,
with two supported hosting postures. Both run exactly one instance.
| Docker on Linux (this page) | Azure App Service | |
|---|---|---|
| Database | SQL Server, or PostgreSQL 16 or later; beside the Server or an instance you already run | Azure SQL, with a managed identity and no password |
| TLS | The Server terminates its own, or a reverse proxy you name does | The platform terminates it |
| Certificate Vault | Available | Available |
| Who schedules a restart | You | The platform, for its own maintenance |
Lacuna Software provides a deployment package containing the image reference, the compose files
(docker-compose.yml for SQL Server, docker-compose.postgresql.yml for PostgreSQL), an annotated
environment sample (.env.example), the one-script deployment (deploy-docker.ps1) and the Group
Policy templates for the fleet. The instructions below assume you have that package on the target
host. The examples use Server 1.24.0, image trustbridge-server:1.24.0.
Before you choose a posture
Five things are true of every deployment and shape how you verify it.
One instance is a rule, not a recommendation. Workstations hold a persistent SignalR connection and there is no backplane, so a second instance knows nothing of the workstations connected to the first. The failure is silent: sessions fail against machines that are connected and invisible. Never scale out.
Every deployment needs a Key Ring Protector. Both of the Server's key rings, the one behind admin
cookies and the one behind the Certificate Vault, are rows in its own database sealed under one X.509
certificate you supply as configuration (KeyRingProtector:Certificate). The Server refuses to start
without it, vault or no vault. Back up the PKCS#12 permanently, somewhere the platform cannot reach.
See Security.
Every deployment needs a PKI SDK licence. The Lacuna PKI SDK licence
(Certificates:Validation:License) is the one the whole fleet runs on: the Server hands its own to
every Admitted machine, and nothing reads a certificate without it. So the Server refuses to start
without one, whether or not it holds a certificate of its own. The image carries none. A licence
that has lapsed does not stop the Server, but nothing in the fleet or the vault can sign until it is
renewed.
The Server brings its own schema up. At every start, before it listens, it applies any pending
migration to its database (Database:MigrateOnStart, default true). A deployment is an image and a
connection string; an upgrade is a new image tag. What that costs is a standing DDL grant
(db_ddladmin, or on PostgreSQL CREATE on the public schema) for the identity the Server connects
as, or the switch set to false and a DBA applying migrations first.
| The database is | What the Server does |
|---|---|
| Up to date | Starts. No DDL is issued. |
| Behind | Applies the missing migrations, names them in the log, then starts. |
Behind, with MigrateOnStart=false | Refuses to start, naming the migrations and the command to run. |
| Ahead of this build (a rollback across a migration) | Refuses to start. Old code on a newer schema would answer wrongly rather than fail. |
| Unreachable | Keeps trying for about ninety seconds, then refuses to start, saying how long it waited. |
Each migration command has a thirty-minute limit of its own; nothing bounds an upgrade as a whole.
"It started" is not "it works". A running Server answers GET / with 200 while its database is
unreachable, on purpose, so that a database blip is not an outage a human has to end. The check that
touches the database is GET /healthz: it makes one SQL round trip and answers Healthy (200) or
Unhealthy (503), needs no credential and writes no Audit Log row. Point your monitoring at it.
SQL Server or PostgreSQL
On Docker the Server runs on SQL Server or, since 1.20.0, on PostgreSQL 16 or later. One image
carries both drivers, and Database:Engine (SqlServer, the
default, or PostgreSql) picks one at start. PostgreSQL needs no extension. The App Service posture
stays on Azure SQL (Azure App Service).
A deployment chooses its engine once, at install, and never moves. Existing deployments stay on SQL
Server with no change. Nothing moves a running installation's Audit Log, Certificate Vault, key rings
and Workstation registry to the other engine, and changing Database:Engine on a deployment that
exists is not a migration: it points the Server at a different, empty database. Where the engines
differ for an operator, this page says so beside the SQL Server form: the compose file and its password
(step 3), the grant the schema step needs and the idempotent script (step 4), and the backups.
The short way: one script
deploy-docker.ps1 (Server 1.21.0 and later) does the steps below for the PostgreSQL form and puts
REST PKI Core beside it, with a RustFS bucket for REST PKI Core's signed documents. It needs PowerShell
7.2 or later and Docker with the Compose plugin, on Linux or on Windows with Docker Desktop:
pwsh ./deploy-docker.ps1
It asks three things: the host name workstations and browsers use to reach this machine (this
machine's DNS name by default; never localhost, because the Server's container calls REST PKI Core by
it), the PKI SDK licence (a path to LacunaPkiLicense.config or to the binary licence, or its base64),
and the first Administrator's e-mail address. Each can be passed instead, as -HostName, -PkiLicense
and -AdminEmail.
It generates everything else: every password, the RustFS keys, the Key Ring Protector, REST PKI
Core's encryption key and root password, and a TLS certificate for the host name issued by a deployment
CA of its own, whose key is discarded once it has issued that one certificate. With -TlsCertificate
(and -TlsCertificatePassword) it uses a PKCS#12 of yours instead, for both TrustBridge and REST PKI
Core. Then it writes .env and docker-compose.yml into the deployment directory (-Path, default
~/trustbridge), starts five containers (PostgreSQL 16 holding a database for TrustBridge and one for
REST PKI Core, RustFS, a one-shot container that creates the bucket, REST PKI Core and the Server),
waits for /healthz to answer Healthy, and prints the addresses and the credentials. TrustBridge
answers on port 443 and REST PKI Core on 8443 (-HttpsPort, -RestPkiPort); the RustFS console
listens on this machine alone. REST PKI Core is set to sign ICP-Brasil AD-RB with no timestamp.
Run it again at any time. It keeps every value already in .env, so a second run upgrades (pass
-Image trustbridge-server:<version>) or repairs, and never re-keys: a new Key Ring Protector, or a new
REST PKI Core encryption key, would leave everything stored under the old one unreadable. It rewrites
docker-compose.yml on every run, so a change made there by hand does not survive. It refuses an
image older than 1.20.0, the first release that runs on PostgreSQL. The image has to be on the machine
or pullable: sign in to the registry that holds it (docker login) and pass its full name with
-Image.
What it does not decide for you:
- REST PKI Core reads only Lacuna's binary licence. Give the script
LacunaPkiLicense.configand it asks for the binary licence as well (or pass it as-RestPkiLicense). Give it the binary licence and both services use it. - Its own CA is trusted by nothing. Workstations connect only once they trust
tls/trustbridge-ca.crtin the deployment directory (Trusted Root Certification Authorities, by Group Policy for a fleet). Otherwise run it again with-TlsCertificateand a certificate they already trust (step 2). - Connecting TrustBridge to REST PKI Core is a second run. The API key exists only once REST PKI
Core is up. Sign in to REST PKI Core as
root, create an application with the Worker role and a key for it, then run the script with-ConnectRestPki. It asks for the key and the security context (REST PKI Core's standard ICP-Brasil context by default), or takes them as-RestPkiApiKeyand-RestPkiSecurityContextId, and points the Server at this deployment's REST PKI Core. Then switch the Client Applications that should sign with it on the Client Applications screen. See REST PKI Core for the security context, the signature policy and the page marks. - The Signer stays as you leave it. It is unconfigured unless
SIGNER_BASEADDRESSandSIGNER_APIKEYare set in.env; the next run keeps them. The Certificate Vault is off in the file it writes. - The deployment directory is the backup. Its
.envholds the only copies of the Key Ring Protector and of REST PKI Core's encryption key; lose either and what is stored under it cannot be read again. The first run also leaves the protector asbackup/key-ring-protector.pfx: copy it somewhere safe, then delete it from the machine.
Read the rest of this page before relying on such a deployment in production: everything below is what the script does on your behalf.
Prerequisites
- A Linux host with Docker Engine and the Compose plugin.
- A TLS certificate as a PKCS#12 whose chain every workstation already trusts, for the Server or for the reverse proxy in front of it. The Workstation Service refuses an untrusted chain, and the symptom is workstations that never appear, with no error on the Server. A self-signed certificate will not do. Use your internal CA or a public one.
- A Key Ring Protector, a second and quite different PKCS#12. Step 3 below makes it.
- The Lacuna PKI SDK licence, the
LacunaPkiLicense.configLacuna issued. Required on every deployment, vault or not. - Lacuna Signer credentials: the base address and the API key in the
application|secretform Signer publishes. Both or neither. An address with no key refuses the start. - Only if a Client Application will sign its documents with REST PKI Core: its address, the API key of an application with the Worker role, and a security context. See REST PKI Core.
- A database: SQL Server, or PostgreSQL 16 or later. Each compose file can run one beside the Server, but read the note on SQL Server's edition.
- Only if you will turn on Certificate Sign-In for the admin console: a Lacuna Web PKI licence in its one-line format, naming the hostname people type to reach the console.
1. The host directory
One directory, which the Server only reads, as uid 1654:
sudo mkdir -p /etc/trustbridge/tls /opt/trustbridge
The Server writes nothing to your disk. The only directory it writes is /tmp inside the container,
in three places:
| Directory | What it holds |
|---|---|
/tmp/trustbridge-pki | The PKI SDK's caches of fetched issuers and CRLs, and the file the PKI licence is handed to the SDK through |
/tmp/trustbridge-logs | The Server's own rolling log file, which the Server log screen reads |
/tmp/trustbridge-releases | A copy of each Workstation Release stored on the Workstation Releases screen, which the fleet's downloads are served from, and an upload while the Server reads it |
All three are copies of something kept elsewhere: the caches refill, every log line went to the
container's log first, and each release copy is rebuilt from its database row on the next download.
Nothing there belongs in a backup. The third is the one that grows: a Workstation Release is about
100 MB, one copy per stored release. If that directory cannot be written, or /tmp is full, the
Workstation Releases screen refuses every upload by name, while downloads are still served from the
database.
If you mount a tmpfs over /tmp, mount a disk-backed volume over /tmp/trustbridge-releases
itself: a tmpfs counts against the container's memory limit.
2. TLS
In the default compose file nothing sits in front of this Server, so it terminates its own TLS:
sudo cp your-certificate.pfx /etc/trustbridge/tls/server.pfx
sudo chown 1654:1654 /etc/trustbridge/tls/server.pfx
sudo chmod 400 /etc/trustbridge/tls/server.pfx
The container listens on 8443; the host publishes 443. An unprivileged process cannot bind
443, so dockerd does and forwards. That mapping is already in the compose file.
To make Test Signatures (from the admin console's Test Signature page, or the public one), add a
plain-HTTP listener on the container's own loopback: ASPNETCORE_URLS: https://+:8443;http://localhost:8080.
A run calls the Server's own contract, and the TLS certificate is issued for the name people type,
which localhost is not. Nothing outside the container can reach that listener, and the compose file
still publishes only 8443. See The Test Signature.
Behind a reverse proxy you already run
If you would rather terminate TLS in a proxy (nginx, HAProxy, a load balancer), the Server serves it plain HTTP:
-
Drop the
Kestrel__Certificates__*variables from the compose file and setASPNETCORE_URLS: http://+:8443. Kestrel told to serve HTTPS with no certificate does not start. -
Publish the port on loopback only:
127.0.0.1:8443:8443in place of443:8443, which the proxy now holds. Nothing then reaches the container through the published port but the host. -
Name the proxy in
ForwardedHeaders__TrustedProxies__0(and__1,__2for more entries: the list must be indexed even with one entry). Use the address the container sees the proxy connect from, which is not always the one you know:- A proxy on the Docker host connects from the bridge network's gateway
(
docker network inspect). - A proxy in another container on the same compose network connects from an address in that network's subnet. Listing the subnet trusts every container on it.
Without this, the Server takes your proxy for every caller: every audit row records the proxy's address, and every browser on the login page and every machine asking for admission share one per-address bound. The start log says what the Server took from the list, in a line beginning
Forwarded headers:. If audit rows still show the proxy's address, the list does not name the address the proxy connects from. The rules for entries are underForwardedHeaders:TrustedProxies. - A proxy on the Docker host connects from the bridge network's gateway
(
The proxy has to send the caller's address and scheme, pass the Host header through (the Server
never reads X-Forwarded-Host, and nginx sends the upstream's name unless told otherwise), and
carry WebSockets. The workstation channel and the admin console are both WebSocket connections.
Without the upgrade headers both fall back to long polling: the console keeps working, only slower,
and the fleet connects as a stream of polling requests, each one checked against the database. For
nginx, at http level, once:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
nginx at the edge, the only proxy in front of the Server, replaces the address header with the one address it saw:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
nginx behind a load balancer that terminates TLS, with both listed in TrustedProxies, keeps what
the balancer wrote: it appends to the address and passes the balancer's scheme on, because its own
$scheme is the http the balancer spoke to it.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
Set all of these in one place: nginx stops inheriting proxy_set_header lines from an outer block as
soon as a location sets one of its own.
Let a Workstation Release through. An upload to /admin/workstation-releases/upload is one
request of up to 256 MB. Raise the proxy's body limit for that path: nginx's client_max_body_size
is 1 MB by default, which refuses every release.
Not ASPNETCORE_FORWARDEDHEADERS_ENABLED. That switch is for App Service: it trusts whoever
connects, so any client that can reach the container's port without going through the proxy could
name its own address. Outside App Service the Server logs a Warning at start when it is set, and
setting it together with TrustedProxies refuses the start. If an earlier deployment of yours set
it, unset it and name the proxy instead.
3. Configuration
cd /opt/trustbridge
cp <package>/docker-compose.yml .
cp <package>/.env.example .env
chmod 600 .env
For PostgreSQL, copy docker-compose.postgresql.yml as docker-compose.yml instead, so that every
docker compose command on this page finds it as written (see
PostgreSQL instead of SQL Server).
The compose file belongs in version control and holds no secret; .env holds every secret and must
not be committed. Server configuration lists every key the Server takes; the ones
this posture needs are below.
.env variable | What it is |
|---|---|
TRUSTBRIDGE_IMAGE | The image, with a specific tag, such as trustbridge-server:1.24.0. Never latest. The SQL Server compose file falls back to its own tag when the line is missing; the PostgreSQL one has no fallback and refuses to start without it. |
MSSQL_SA_PASSWORD | The bundled SQL Server's sa password (8+ characters from three character classes), read by docker-compose.yml. Leave it empty on PostgreSQL. |
POSTGRES_PASSWORD | The bundled PostgreSQL's password, read by docker-compose.postgresql.yml. Letters and digits only (openssl rand -hex 24), because it goes into a connection string as written. Leave it empty on SQL Server. |
TLS_CERT_PASSWORD | The password on /etc/trustbridge/tls/server.pfx. Not needed behind a reverse proxy. |
KEY_RING_PROTECTOR_CERTIFICATE | The Key Ring Protector, one line of base64. Required. |
KEY_RING_PROTECTOR_PASSWORD | Only if that PKCS#12 has a password. |
PKI_LICENSE | The Lacuna PKI SDK licence, base64 on one line. Required. The compose file refuses to start the Server with it empty. |
VAULT_ENABLED | false unless this Server will hold Vault Certificates. |
SIGNER_BASEADDRESS, SIGNER_APIKEY | Lacuna Signer. Both or neither. |
ADMIN_BOOTSTRAP_EMAIL, ADMIN_BOOTSTRAP_PASSWORD | The first administrator. Read only while the account table is empty; remove after the first sign-in. |
Using a SQL Server you already run
That is the normal production shape. Delete the sqlserver service from the compose file, drop the
depends_on, and point ConnectionStrings__TrustBridge at the real instance:
ConnectionStrings__TrustBridge: >-
Server=sql.example.com,1433;Database=TrustBridge;User Id=trustbridge;
Password=${DB_PASSWORD};Encrypt=True
The identity needs db_datareader, db_datawriter and, for the Server to apply its own migrations,
db_ddladmin. The database itself must exist. Applying a migration never creates a database, except
on the bundled posture where the Server connects as sa and can.
If you keep the bundled database, note that the compose file sets MSSQL_PID=Developer, which
Microsoft licenses for development and test only, not for production. Set an edition you hold a
licence for, or use a SQL Server your organization already runs and backs up. Nothing will warn you.
PostgreSQL instead of SQL Server
cp <package>/docker-compose.postgresql.yml /opt/trustbridge/docker-compose.yml
In .env, set POSTGRES_PASSWORD and leave MSSQL_SA_PASSWORD empty, and keep TRUSTBRIDGE_IMAGE.
The image reads the password only when it initialises an empty volume, so changing it later is an
ALTER ROLE inside the database as well as the line in .env.
What the file sets differently is three things:
Database__Engine: PostgreSql, which the Server reads once at start.- A connection string in Npgsql's spelling (
Host=,Username=, andGSS Encryption Mode=Disable, which keeps a harmless Kerberos probe out of the log). It points at PostgreSQL itself, never through a pooler in transaction mode (PgBouncer's, or the one Azure Database for PostgreSQL offers on port 6432): the schema step's lock belongs to one database session. - A
postgres:16service, not published to the host, whose image creates thetrustbridgeuser as a superuser and thetrustbridgedatabase on the first start of its empty volume, so the Server has the rights to bring its schema up, assahas in the SQL Server file.
Using a PostgreSQL you already run (16 or later): delete the postgres service, drop depends_on,
and point the connection string at it with SSL Mode=Require or stricter, because Npgsql's default
falls back to plain text when the server offers no TLS:
ConnectionStrings__TrustBridge: >-
Host=pg.example.com;Port=5432;Database=trustbridge;Username=trustbridge;
Password=${DB_PASSWORD};SSL Mode=Require;GSS Encryption Mode=Disable
The identity needs CREATE on the public schema (or its ownership) of an empty database a DBA made,
or CREATEDB for the Server to make it. There is no licence edition to choose: PostgreSQL has one.
The Key Ring Protector
Make one. A self-signed RSA certificate is the right shape: nothing ever validates it as a certificate, so there is nothing for a CA to attest. It is a key pair with a thumbprint on it.
openssl req -x509 -newkey rsa:2048 -nodes -days 18250 \
-keyout protector.key -out protector.crt \
-subj "/CN=TrustBridge Key Ring Protector"
openssl pkcs12 -export -inkey protector.key -in protector.crt -out protector.pfx -passout pass:
base64 -w0 protector.pfx
- RSA, not ECDSA. The framework seals key material through
EncryptedXml, which uses RSA only. An ECDSA certificate is refused at start. - Fifty years of validity. A protector's dates are never checked, but a protector you feel obliged to replace is one you then have to keep forever anyway.
base64 -w0: one line, no PEM header, which is what the setting reads.- No password is the honest choice: the only place to keep one would be the same
.envas the bytes it opens.
Paste the base64 into .env as KEY_RING_PROTECTOR_CERTIFICATE. Then put protector.pfx somewhere
this host cannot reach, and remove it from the host:
shred -u protector.key protector.pfx # only after copying the .pfx somewhere safe
Losing the protector destroys the Certificate Vault, and nothing recovers it. Not Lacuna, not you. There is deliberately no escrow. It is one small file that never changes: put it wherever your organization keeps the things it cannot re-create.
Rotation is additive. See Operations.
The PKI SDK licence
Every deployment needs it, vault or not. It is a setting rather than a file, so it goes in .env
beside the Key Ring Protector, as the whole LacunaPkiLicense.config base64-encoded on one line:
echo "PKI_LICENSE=$(base64 -w0 LacunaPkiLicense.config)" >> .env
It expires, and a lapsed one does not stop the Server. The Server starts, logs one Critical line
naming Certificates:Validation:License, and every admin page carries a banner saying that nothing in
the fleet and nothing in the vault can sign until it is renewed. Renewing it is replacing the line in
.env and recreating the container, which also hands the new licence to every machine as it
reconnects. See Operations.
Turning the Certificate Vault on
VAULT_ENABLED=false is the default and the majority posture: every private key on a workstation.
Set it to true only for a Server that will hold Vault Certificates. It needs no licence beyond the
one above, but it is one more thing that licence is load-bearing for: with it lapsed, every Vault
Certificate also reports UNTRUSTED and an import is refused. Read
Security first: a PKCS#12 to import has to be in the legacy PFX
shape.
REST PKI Core is off unless you configure it
A Client Application can have its documents signed by REST PKI Core instead of the Signer, and only on
a Server whose RestPkiCore section is set: all three keys or none, the API key kept as a secret like
the Signer's. Add them to the Server's environment: in the compose file, with the key's value in
.env:
environment:
RestPkiCore__Endpoint: https://core.pki.rest/
RestPkiCore__ApiKey: ${RESTPKI_API_KEY}
RestPkiCore__SecurityContextId: <the security context's id>
Read REST PKI Core first: the key needs the Worker role, trust is the security context's rather than the Trusted CAs', the signature policy and the page marks are set on REST PKI Core's side, and this container must reach REST PKI Core's site address as well as the endpoint, because that is where a signed document is downloaded from.
4. The schema
Nothing to do. The Server creates and migrates its own schema at start, on either engine. Skip to step 5.
Two reasons you might do it yourself: your change control will not grant DDL to the running Server, or
a DBA wants to read the SQL first. In either case set Database__MigrateOnStart: "false" in the
compose file and, before each start on a new version, either apply migrations with the .NET SDK and a
checkout at the deployed version, or generate an idempotent script and apply it with sqlcmd:
dotnet tool restore
dotnet ef migrations script --idempotent --project src/TrustBridge.Server -o schema.sql
sqlcmd -S <host> -U <user> -P <password> -C -I -b -d TrustBridge -i schema.sql
-I is required (QUOTED_IDENTIFIER ON, or the first filtered index fails with Msg 1934), and so
is -b (or a partial apply reports success). If you run dotnet ef database update instead, append
Command Timeout=3600 to the connection string: the tool's default is thirty seconds, which an index
build over a large AuditEvents table outlasts.
On PostgreSQL the reasons and the default are the same: the bundled trustbridge user is a
superuser, so the Server creates and migrates its own database. Narrowed, the identity needs CREATE on
the public schema (or its ownership) to apply a migration, and CREATEDB only if it is to create the
database itself; a refused start names whichever is missing. Each engine has its own migrations, so
tell the tool the engine, or it writes SQL Server's script, which PostgreSQL refuses on its first
line. Apply the script with psql -v ON_ERROR_STOP=1, which is to psql what -b is to sqlcmd:
without it psql carries on past a failed statement, exits 0 and leaves part of the script applied.
There is no counterpart to -I.
dotnet tool restore
dotnet ef migrations script --idempotent --project src/TrustBridge.Server -o schema.sql \
-- --Database:Engine=PostgreSql
docker compose exec -T postgres psql -U trustbridge -d trustbridge -v ON_ERROR_STOP=1 < schema.sql
That runs psql inside the bundled database's container, so no port has to be published; against a
PostgreSQL you already run, use psql -h <host> -U <user> -d trustbridge -v ON_ERROR_STOP=1 -f schema.sql.
The database has to exist, and applying the script twice is safe. dotnet ef database update takes the
same trailing -- --Database:Engine=PostgreSql, with Command Timeout=3600 in its connection string.
With the switch off, the Server refuses to start on a pending migration rather than starting without it. That is the point of the switch.
5. Start it
cd /opt/trustbridge && docker compose up -d
docker compose logs -f server
A healthy first start:
info: TrustBridge.Server.Common.KeyRings.KeyRingPosture[0]
Every key ring this Server keeps in its database is encrypted with the Key Ring Protector
76766DC2741A8DA070CC47E3830D34023D9ED885, and 0 retired protector(s) are kept readable beside
it. Losing a protector makes every key ever sealed under it — and so every Vault Certificate
it wrapped — unrecoverable, so backing them all up, permanently, is part of running this
Server (ADR-0024).
info: TrustBridge.Server.Common.Vault.VaultReadiness[0]
The Certificate Vault is off on this Server, so it holds no certificates of its own. Every
private key it signs with is on a workstation (ADR-0020).
info: TrustBridge.Server.Common.Admin.AdminAccountBootstrap[0]
Created the first admin account admin@example.com in the Administrator role.
info: Now listening on: https://[::]:8443
info: Application started. Press Ctrl+C to shut down.
The first line is the one to read. It names the protector thumbprint your backup has to contain
and counts the retired protectors this Server can still read with. If you have rotated once and it
says 0 retired protector(s), the old one is not configured and everything sealed under it is
unreadable.
With the vault on, the second line reads The Certificate Vault is open: its key ring holds N key(s)…
instead. A warn in its place saying the vault is closed until they have run means the Server came
up before its database did. That is the vault latch.
Behind a reverse proxy, the Server listens on http://[::]:8443, and a line beginning
Forwarded headers: names the proxies it trusts.
On PostgreSQL, the first start of an empty database logs several fail lines before it applies
anything, and they are not a fault. Each is the database provider reading the migrations history
before the table exists:
fail: Microsoft.EntityFrameworkCore.Database.Command[20102]
Failed executing DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
The line that settles it comes after them, a SchemaUpgrade line saying which migrations it applied.
Every later start says The database holds every migration this Server was built with and logs no
fail at all.
6. Check it actually works
curl -s https://trustbridge.example.com/healthz # Healthy
curl -o /dev/null -w '%{http_code}\n' https://trustbridge.example.com/ # 200 front door
curl -o /dev/null -w '%{http_code}\n' https://trustbridge.example.com/admin # 302 to login
curl -o /dev/null -w '%{http_code}\n' https://trustbridge.example.com/api/v1/sessions # 401 no API key
Then sign in at /admin with the bootstrap account. That reads the database through the path a
real request takes rather than the one a probe does. Set a real password, enroll the second factor,
then remove ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD from .env and recreate the
container. They are inert after the first start; removing them is hygiene.
The bootstrap account signs in with a password, so leave Admin:CertificateSignIn:Required off for
the first start. If you mean to switch passwords off later, create a second Administrator first: a
Bound CPF is always set by another Administrator, never by the account's owner, and an account
without one cannot sign in once passwords are off. See
Admin:CertificateSignIn:Required.
Finally, register a Client Application at /admin/applications, issue an API key, and drive the
REST API once end to end.
Backups
| What | How | If you lose it |
|---|---|---|
| The database | BACKUP DATABASE, or your existing SQL Server backups. On PostgreSQL, pg_dump (below), or your existing PostgreSQL backups | Sessions, Audit Log, Certificate Registry, Workstation Releases, both key rings and the vault's ciphertext |
The Key Ring Protector .pfx | Copy the file once. It never changes | Every Vault Certificate is unopenable, permanently, and every administrator signs in again |
Every retired protector .pfx | The same, kept forever | Whatever was sealed under that one, permanently |
/etc/trustbridge/tls | Wherever your certificates live | Reissue |
| The container's log, wherever your logging driver sends it | Your log retention, not a file copy | Audit rows the database could not take. The trail has a hole exactly where the database was down |
The compose file's json-file logging driver keeps lines under the container's own directory, and
Docker deletes them with the container, so docker compose down and the upgrade below both
discard them. Point the logging: driver at something off this box, or run a log agent over the
files, before you need them.
On PostgreSQL the bundled database is backed up with pg_dump, not by copying its volume. The
volume's files are a running server's, and a copy taken under it is not a backup. pg_dump is already
inside the database's container and writes a consistent snapshot while the Server keeps working; the
custom format (-Fc) is compressed and is what pg_restore reads:
cd /opt/trustbridge
docker compose exec -T postgres pg_dump -U trustbridge -Fc trustbridge > trustbridge-$(date +%F).dump
Restore into an empty database (the volume of a new deployment, or one you dropped and created again) with the Server stopped, so nothing writes while it is half there:
docker compose stop server
docker compose exec -T postgres pg_restore -U trustbridge -d trustbridge --clean --if-exists \
< trustbridge-2026-10-20.dump
docker compose start server
Neither engine's backup restores into the other. Copy the dumps off this host, like the rest of the
table. A deployment made with deploy-docker.ps1 also keeps its deployment directory, whose .env is
the only copy of the Key Ring Protector and of REST PKI Core's encryption key.
Restoring is two things arriving together. A database without its protector is a refused start; a protector without the whole database (vault rows present, key ring rows absent) is refused for the mirror-image reason.
Upgrades
Back up the database first. That backup is your way back (below).
docker compose pull && docker compose up -d
The new image applies whatever migrations it needs on its way up and names them in the log. If you set
Database__MigrateOnStart: "false", apply the migrations first; the new image refuses to start
against a schema that is behind. A deployment made with deploy-docker.ps1 upgrades by running it
again with -Image trustbridge-server:<version>.
On PostgreSQL, pull moves postgres:16 to 16's latest minor release, and never to 17. A new
major is a new data format: pointed at a 16 volume, postgres:17 refuses to start. Moving the bundled
database to a later major is a pg_dump from 16 and a pg_restore into an empty 17 volume, as in
Backups, and it is yours to schedule: the Server needs only 16 or later.
Expect visible downtime of a minute or two: one instance means no rolling restart. Workstations
reconnect by themselves and workstation-bound sessions survive verbatim. Sessions bound to a Vault
Certificate do not; they end as Stranded and clients create new ones.
Rolling back is restoring that backup, not swapping the tag. Most Server releases since 1.9.0 carry migrations (the Changelog says which; 1.14.1, 1.16.0, 1.19.0, 1.22.0 and 1.24.0 do not), and an older image refuses to start against a database a newer one has migrated. To go back, restore the pre-upgrade backup and deploy the previous tag against it. Going back from a release with no migration to the one before it is the exception, and a swap of the tag: 1.24.0 to 1.23.0, for one.
Upgrading to 1.17.0 or later with Sessions:SignatureAllowance set: the key is no longer read, so
the deployment's sessions are unbounded until an Administrator sets the Signature Allowance on each
Client Application that needs one, on the Client Applications screen. Do that straight after the
upgrade (1 for an integrator that asked for single-use sessions), then remove the key. The Server
logs a Warning at every start while it is set. See
Sessions:SignatureAllowance.
Upgrading to 1.18.0 or later with Signing:StrandedAfter set: it must now exceed twice
Signing:RequestBudget plus Signing:SignerCancelBudget, or the Server refuses to start, naming the
key. The defaults clear it.
Upgrading to 1.20.0 or later on SQL Server: nothing changes until you ask; Database:Engine
defaults to SqlServer. One of that release's migrations upper-cases the thumbprints the database
holds. On a database made with a case-sensitive collation, it refuses, changing nothing, if two rows
differ only in case, and names the table.
Upgrading to 1.23.0 or later: three migrations run at start, on both engines.
ReportWorkstationConsole adds seven nullable columns to Workstations and RecordTheRunsFailureCause
one to SigningOperations; both are quick. IndexTheAuditLogByWorkstationAndTime builds an index on
the Audit Log, and its length grows with the Audit Log. On SQL Server, upgrade to 1.24.0 rather than
1.23.0 when the Audit Log is large. 1.23.0 builds that index offline, and an offline build blocks
writes to the Audit Log for most of its length, so Audit Log rows written meanwhile can time out and be
lost. 1.24.0 builds the same index online where the edition can: at low priority on Azure SQL Database,
Azure SQL Managed Instance and Enterprise or Developer 2022 or later, so the build waits behind writes
rather than in front of them; plain online on older Enterprise; and still offline on Standard, Express
and Web, which cannot build online, so on those editions schedule the upgrade for a quiet hour.
PostgreSQL builds it the same way on both releases, with a plain build that also holds writes back
while it runs, so a large Audit Log there wants a quiet hour too. A database that already applied the
migration under 1.23.0 needs nothing more from 1.24.0. If one migration command runs past its own time
limit, see Database:MigrateOnStart.
Coming from Server 1.6.x, set PKI_LICENSE in .env before you upgrade: since 1.7.0 every
deployment needs it, and the compose file will not start the Server without it. Behind a reverse
proxy configured with ASPNETCORE_FORWARDEDHEADERS_ENABLED, move to ForwardedHeaders:TrustedProxies
as in step 2.
Upgrading to 1.15.0 or later moves the workstation protocol to 6. A Server accepts the current workstation protocol and the one before it, so its window is 5–6:
| A machine on | Against Server 1.15.0 or later |
|---|---|
| Workstation 1.6.0 to 1.10.0 (protocol 6) | Works. It cannot connect to a Server older than 1.15.0. |
| Workstation 1.5.0 (protocol 5) | Works, and serves every session, but is never chosen for a session whose Client Application has Per-Transaction PIN on. |
| Workstation 1.4.x or older | Refused at the handshake with update required. It takes a current release by hand or by Group Policy. |
So before upgrading, move any machine still on 1.4.x to Workstation 1.5.0 (the Workstations
screen's Service version column shows them). Then upgrade the Server, upload
TrustBridge-Workstation-1.10.0-x64.msi on the Workstation Releases screen and offer it to the
fleet on the Workstations screen. Machines with updates on install it themselves; the others need
the MSI pushed to them. Deliver Workstation 1.8.0 and later with Server 1.20.0 or later, the first
Server that takes their Workstation Report. An earlier Server closes the connection of a 1.8.0 machine
whose report is larger than 32 KB, which one with many tokens, middleware or errors can send; from
1.8.1 a machine keeps its reports under 32 KB until the Server has taken one, and stays connected.
The console Workstation 1.10.0 reports shows on the Workstations screen from Server 1.23.0. Turn Per-Transaction PIN on for a Client Application only once its holders' machines run 1.6.0
or later. See
Rolling a Server and its fleet across protocol 6
and Server-delivered updates.
Upgrading from Server 1.14.x with the Test Signature on: remove the TestSignature: keys that are
no longer read, and revoke the key TestSignature:ApiKey held (see
The Test Signature).
A database created by Server 1.2.0 or earlier cannot be upgraded: the migration history was squashed into one baseline afterwards, and such a database is refused at start as one carrying migrations this build does not contain. Drop it and let the Server create a new one.
When it will not start
| Log says | Cause |
|---|---|
KeyRingProtector:Certificate is not configured | No protector. Every deployment needs one. |
Certificates:Validation:License is not configured | No PKI SDK licence. Every deployment needs one, vault or not. |
set PKI_LICENSE in .env (from docker compose, before the container starts) | PKI_LICENSE is empty in .env. The same message names TLS_CERT_PASSWORD or KEY_RING_PROTECTOR_CERTIFICATE when those are empty. |
… is not base64, so it cannot be a PKCS#12 | The value has a PEM header or line breaks. Use base64 -w0. |
… could not be opened as a PKCS#12 | Wrong CertificatePassword, or the bytes are not a PKCS#12. The Server cannot tell those apart. |
… carries no RSA private key this process can use | An ECDSA certificate, or one exported without its key. |
The Certificate Vault's key ring holds N key(s) and the Key Ring Protector configured here opens none of them | A swapped protector. Put the old one back, or add it to RetiredCertificates. |
The Certificate Vault holds N certificate(s) and its key ring is empty | A partial restore. Restore the whole database. |
Signer:ApiKey is required when Signer:BaseAddress is configured | Half-configured Signer. Set both or neither. |
RestPkiCore:… is required when any RestPkiCore key is set | Half-configured REST PKI Core. Set all three or none. |
RestPkiCore:Endpoint must be an https address / must end in a slash | Write the endpoint as https://…/. |
Database:Engine is PostgreSql, but ConnectionStrings:TrustBridge is written for SQL Server | The engine and the connection string disagree, usually Database__Engine set and the SQL Server string kept. The reverse names PostgreSQL. A deployment does not move between engines; fix whichever is wrong. |
Database:Engine is '…', which names no engine this Server runs on | A typo. SqlServer or PostgreSql, in any case. |
Signing:StrandedAfter must be greater than twice Signing:RequestBudget | A StrandedAfter set below the floor that 1.18.0 raised. Raise it, or remove it to take the default. |
Dashboard:TimeZone names '…' | A zone the Server does not know, or a Windows zone id. Write the IANA zone, such as America/Sao_Paulo. |
ForwardedHeaders:TrustedProxies:N is '…' | An entry that is not a plain address or network, trusts every address (/0), has bits set past its prefix, or is IPv4-mapped or link-local. Write the proxy's own address, as the Server prints it. |
ForwardedHeaders:TrustedProxies is the one value '…' | The list given as a single value. Index it: ForwardedHeaders__TrustedProxies__0. |
ForwardedHeaders:TrustedProxies is set, and so is ASPNETCORE_FORWARDEDHEADERS_ENABLED | Both set. Outside App Service, unset the variable. |
Admin:CertificateSignIn:Enabled is on, but Admin:CertificateSignIn:WebPkiLicense is not set | Certificate Sign-In needs the Web PKI licence, in its one-line format. |
Admin:CertificateSignIn:Required is on, which switches passwords off, but Admin:CertificateSignIn:Enabled is off | A console with no way in. Turn Enabled on, or Required off. |
Workstations:ClientTimeout of … is shorter than / is longer than | Outside 10 seconds to 5 minutes. Write it as hh:mm:ss: a bare 15 is fifteen days. |
Language:DefaultCulture refused | A default language outside the supported set. |
… is behind the build pointed at it by N migration(s) | MigrateOnStart is off and the schema needs those migrations. |
… has N migration(s) applied that this build does not contain | An older image against a newer schema (restore the pre-upgrade backup), or a database from 1.2.0 or earlier. |
This Server's database did not answer in N seconds | The database never answered. Check the connection string and that SQL Server (or PostgreSQL) is up. |
… could not apply N pending migration(s) | Usually no DDL rights for the connecting identity: db_ddladmin on SQL Server, CREATE on the public schema on PostgreSQL. |
… needs the CREATEDB attribute | PostgreSQL: the database is absent and the identity may not create it. Grant CREATEDB, or have a DBA create the empty database. |
… ran out of time applying N pending migration(s) | One migration command outlasted thirty minutes. Nothing was half-applied. |
… database stopped answering while it applied N pending migration(s) | The connection failed while migrating. Check that the database is up and nothing restarted it. |
| Starts, but every admin page shows a licence banner | The PKI SDK licence has lapsed or was refused. The Server runs, and nothing can sign until it is renewed. |
| Starts, but workstations never connect | The TLS chain is not trusted by the workstations, or the machines run a Workstation release this Server refuses (update required on their fleet rows). |
Two framework lines that look like protector faults and are not: KeyRingProvider[48] An error occurred while reading the key ring and XmlKeyManager[24] … Unable to retrieve the decryption key.
On a Server that started cleanly they mean the database is unreachable. The protector's own
refusals all name KeyRingProtector: and stop the host.