Pular para o conteúdo principal

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
DatabaseSQL Server, or PostgreSQL 16 or later; beside the Server or an instance you already runAzure SQL, with a managed identity and no password
TLSThe Server terminates its own, or a reverse proxy you name doesThe platform terminates it
Certificate VaultAvailableAvailable
Who schedules a restartYouThe 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 isWhat the Server does
Up to dateStarts. No DDL is issued.
BehindApplies the missing migrations, names them in the log, then starts.
Behind, with MigrateOnStart=falseRefuses 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.
UnreachableKeeps 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.config and 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.crt in the deployment directory (Trusted Root Certification Authorities, by Group Policy for a fleet). Otherwise run it again with -TlsCertificate and 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 -RestPkiApiKey and -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_BASEADDRESS and SIGNER_APIKEY are 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 .env holds 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 as backup/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.config Lacuna issued. Required on every deployment, vault or not.
  • Lacuna Signer credentials: the base address and the API key in the application|secret form 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:

DirectoryWhat it holds
/tmp/trustbridge-pkiThe PKI SDK's caches of fetched issuers and CRLs, and the file the PKI licence is handed to the SDK through
/tmp/trustbridge-logsThe Server's own rolling log file, which the Server log screen reads
/tmp/trustbridge-releasesA 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:

  1. Drop the Kestrel__Certificates__* variables from the compose file and set ASPNETCORE_URLS: http://+:8443. Kestrel told to serve HTTPS with no certificate does not start.

  2. Publish the port on loopback only: 127.0.0.1:8443:8443 in place of 443:8443, which the proxy now holds. Nothing then reaches the container through the published port but the host.

  3. Name the proxy in ForwardedHeaders__TrustedProxies__0 (and __1, __2 for 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 under ForwardedHeaders:TrustedProxies.

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 variableWhat it is
TRUSTBRIDGE_IMAGEThe 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_PASSWORDThe bundled SQL Server's sa password (8+ characters from three character classes), read by docker-compose.yml. Leave it empty on PostgreSQL.
POSTGRES_PASSWORDThe 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_PASSWORDThe password on /etc/trustbridge/tls/server.pfx. Not needed behind a reverse proxy.
KEY_RING_PROTECTOR_CERTIFICATEThe Key Ring Protector, one line of base64. Required.
KEY_RING_PROTECTOR_PASSWORDOnly if that PKCS#12 has a password.
PKI_LICENSEThe Lacuna PKI SDK licence, base64 on one line. Required. The compose file refuses to start the Server with it empty.
VAULT_ENABLEDfalse unless this Server will hold Vault Certificates.
SIGNER_BASEADDRESS, SIGNER_APIKEYLacuna Signer. Both or neither.
ADMIN_BOOTSTRAP_EMAIL, ADMIN_BOOTSTRAP_PASSWORDThe 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.

caution

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=, and GSS 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:16 service, not published to the host, whose image creates the trustbridge user as a superuser and the trustbridge database on the first start of its empty volume, so the Server has the rights to bring its schema up, as sa has 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 .env as 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
danger

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​

WhatHowIf you lose it
The databaseBACKUP DATABASE, or your existing SQL Server backups. On PostgreSQL, pg_dump (below), or your existing PostgreSQL backupsSessions, Audit Log, Certificate Registry, Workstation Releases, both key rings and the vault's ciphertext
The Key Ring Protector .pfxCopy the file once. It never changesEvery Vault Certificate is unopenable, permanently, and every administrator signs in again
Every retired protector .pfxThe same, kept foreverWhatever was sealed under that one, permanently
/etc/trustbridge/tlsWherever your certificates liveReissue
The container's log, wherever your logging driver sends itYour log retention, not a file copyAudit 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 onAgainst 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 olderRefused 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 saysCause
KeyRingProtector:Certificate is not configuredNo protector. Every deployment needs one.
Certificates:Validation:License is not configuredNo 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#12The value has a PEM header or line breaks. Use base64 -w0.
… could not be opened as a PKCS#12Wrong CertificatePassword, or the bytes are not a PKCS#12. The Server cannot tell those apart.
… carries no RSA private key this process can useAn 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 themA swapped protector. Put the old one back, or add it to RetiredCertificates.
The Certificate Vault holds N certificate(s) and its key ring is emptyA partial restore. Restore the whole database.
Signer:ApiKey is required when Signer:BaseAddress is configuredHalf-configured Signer. Set both or neither.
RestPkiCore:… is required when any RestPkiCore key is setHalf-configured REST PKI Core. Set all three or none.
RestPkiCore:Endpoint must be an https address / must end in a slashWrite the endpoint as https://…/.
Database:Engine is PostgreSql, but ConnectionStrings:TrustBridge is written for SQL ServerThe 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 onA typo. SqlServer or PostgreSql, in any case.
Signing:StrandedAfter must be greater than twice Signing:RequestBudgetA 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_ENABLEDBoth set. Outside App Service, unset the variable.
Admin:CertificateSignIn:Enabled is on, but Admin:CertificateSignIn:WebPkiLicense is not setCertificate 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 offA console with no way in. Turn Enabled on, or Required off.
Workstations:ClientTimeout of … is shorter than / is longer thanOutside 10 seconds to 5 minutes. Write it as hh:mm:ss: a bare 15 is fifteen days.
Language:DefaultCulture refusedA 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 containAn 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 secondsThe 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 attributePostgreSQL: 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 bannerThe PKI SDK licence has lapsed or was refused. The Server runs, and nothing can sign until it is renewed.
Starts, but workstations never connectThe 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.

View as Markdown