Azure App Service
The Server as a Linux custom container on Azure App Service, one instance, talking to Azure SQL with a managed identity and no password anywhere. Read Server installation first for the five things every deployment shares, in particular the Key Ring Protector, the PKI SDK licence and the single-instance rule.
Everything on the Docker page about configuration keys, the protector, the vault and backups applies here unchanged. What differs is where TLS is terminated, who schedules restarts, and the Azure-specific settings below.
What you cannot do here
No scaling out. One instance, for the reason given on the installation page. Nothing in the product will refuse a second instance; it will simply misbehave. Check the instance count after any plan change and alert on it if you can.
No PostgreSQL. This posture is supported on Azure SQL alone. Its promise is a managed identity and
no password anywhere, and on Azure Database for PostgreSQL that would take an Entra token the Server
does not obtain, so a PostgreSQL here would need a password that no other part of this posture carries.
Nothing in the Server refuses PostgreSQL here, so leave
Database:Engine at its default. The Docker posture is where
PostgreSQL runs (SQL Server or PostgreSQL).
1. The App Service plan
The fleet decides one thing about the SKU: not Free, which caps WebSocket connections at five and answers 429 beyond that, where each Workstation Service holds one connection continuously. Above Free the WebSocket ceiling is not a constraint. Always On is required (below) and is a dedicated-tier feature, so size the plan for the signing work instead. P1V3 is a reasonable start.
az appservice plan create -g <rg> -n <plan> --is-linux --sku P1V3 --number-of-workers 1
2. Azure SQL
az sql server create -g <rg> -n <sqlserver> --enable-ad-only-auth \
--external-admin-name "<your-group>" \
--external-admin-principal-type Group \
--external-admin-sid <the-group's-object-id>
az sql db create -g <rg> --server <sqlserver> -n TrustBridge --service-objective S1
Use a group as the Entra administrator rather than a person who can leave. Allow the app through the firewall: "Allow Azure services" without VNet integration, a private endpoint with it. Leaving public network access disabled with no private endpoint refuses every connection with error 47073, and the Server spends its whole start-up budget waiting on it.
3. The image
Any registry works. Azure Container Registry is shown because App Service can pull from it with a managed identity instead of a stored password.
az acr login -n <registry>
docker tag trustbridge-server:1.24.0 <registry>.azurecr.io/trustbridge-server:1.24.0
docker push <registry>.azurecr.io/trustbridge-server:1.24.0
Use an immutable tag. With DOCKER_ENABLE_CI on, App Service re-pulls on push, so a moving tag
lets the platform restart the Server unattended, and every restart ends live sessions bound to a Vault
Certificate.
4. Create the app
az webapp create -g <rg> -p <plan> -n <app> \
--container-image-name <registry>.azurecr.io/trustbridge-server:1.24.0
az webapp identity assign -g <rg> -n <app>
az role assignment create --role AcrPull \
--assignee-object-id $(az webapp identity show -g <rg> -n <app> --query principalId -o tsv) \
--assignee-principal-type ServicePrincipal \
--scope $(az acr show -n <registry> --query id -o tsv)
az webapp config set -g <rg> -n <app> --acr-use-identity true
az webapp restart -g <rg> -n <app>
Three things that all present as an app that never starts with UNAUTHORIZED in its container log:
- On an ABAC-enabled registry
AcrPullgrants nothing. A registry inRBAC Registry + ABAC Repository Permissionsmode wantsContainer Registry Repository Reader. Check withaz acr show -n <registry> --query roleAssignmentMode -o tsv. - The registry has to accept ARM audience tokens, or a managed-identity pull is refused with
token validation failed:az acr config authentication-as-arm update -r <registry> --status enabled. - The restart is not ceremony. The app was pointed at a private image before it had an identity or a role, so its first pull failed, and nothing retries that on its own.
An app created in the portal is a sitecontainers app: its image is set with
az webapp sitecontainers rather than az webapp config container, and a settings change alone does
not always recycle it, so restart explicitly.
5. Let the app into the database
Connect to the database as its Entra administrator and create the identity as a user:
CREATE USER [<app>] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [<app>];
ALTER ROLE db_datawriter ADD MEMBER [<app>];
ALTER ROLE db_ddladmin ADD MEMBER [<app>];
db_ddladmin is what lets the Server bring its own schema up, which it does by default. Leave it out
only if you also set Database__MigrateOnStart=false and apply migrations yourself before every
upgrade. Omitting the grant and leaving the default on turns your next upgrade into a refused start.
6. Settings
az webapp config appsettings set -g <rg> -n <app> --settings \
WEBSITES_PORT=8080 \
WEBSITES_CONTAINER_START_TIME_LIMIT=1800 \
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
KeyRingProtector__Certificate="$(base64 -w0 protector.pfx)" \
Certificates__Validation__License="$(base64 -w0 LacunaPkiLicense.config)" \
ConnectionStrings__TrustBridge="Server=tcp:<sqlserver>.database.windows.net,1433;Database=TrustBridge;Authentication=Active Directory Managed Identity;Encrypt=True" \
Signer__BaseAddress="https://signer.example.com" \
Signer__ApiKey="<application|secret>" \
Admin__Bootstrap__Email="admin@example.com" \
Admin__Bootstrap__Password="<a strong password>"
From PowerShell or cmd on Windows, put the settings in a JSON file and pass --settings @params.json:
a Signer API key carries a |, which the shell otherwise reads as a pipe.
| Setting | Why |
|---|---|
WEBSITES_PORT=8080 | The container listens on 8080. App Service auto-detects 80 and 8080; setting it removes the ambiguity. |
WEBSITES_CONTAINER_START_TIME_LIMIT=1800 | This posture's schema-migration setting. The Server migrates before it listens, and App Service kills a container that has not answered on its port within this many seconds. The default is 230, so an upgrade with a long index build is killed, restarted and killed again in a loop that reports nothing. 1800 is the platform maximum and costs nothing when migrations are quick. |
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true | The platform terminates TLS at its front ends. Without this the Server sees plain HTTP from a datacentre address: cookies lose Secure, absolute URLs come out http://, and every audit row records the proxy instead of the caller. It trusts whoever connects, for one hop, which is right here because the front ends are the only way in. Leave ForwardedHeaders:TrustedProxies unset: setting both refuses the start. |
KeyRingProtector__Certificate | Required. See below for a Key Vault reference instead of an app setting. |
Certificates__Validation__License | Required, vault or not: the Lacuna PKI SDK licence the whole fleet runs on, the whole LacunaPkiLicense.config base64-encoded on one line. Without it the Server refuses to start. See below. |
ConnectionStrings__TrustBridge | Managed identity, no password. |
Admin__Bootstrap__* | First start only. Remove after signing in. |
WEBSITES_ENABLE_APP_SERVICE_STORAGE is deliberately absent: this deployment needs no persistent
storage. Both key rings are rows in Azure SQL, and the only directory the container writes is /tmp.
Its largest tenant is a copy of each stored Workstation Release, about 100 MB apiece, which the next
download rebuilds from the database when it is lost.
Logging
Turn container logging on, because an audit row the database will not take is written to the
Server log at Critical and nothing else holds it. Container stdout is not retained by default on
Linux App Service; az webapp log tail is a live stream, not a record.
az webapp log config -g <rg> -n <app> --docker-container-logging filesystem
That is a floor: the filesystem sink is capped and rolls. A deployment that means to keep the trail
sends logs to Application Insights or a Log Analytics workspace with a retention it has chosen. When a
workspace is attached through the portal, APPLICATIONINSIGHTS_CONNECTION_STRING is set and the
Server sends every log line there as a trace carrying the request's correlation id, with no further
setting. Logging__ApplicationInsights__ConnectionString is the same value under the Server's own
key for a workspace attached any other way.
Pin the platform
az webapp update -g <rg> -n <app> --client-affinity-enabled true --https-only true
az monitor autoscale list -g <rg> -o table # then delete any that exist
az webapp config set -g <rg> -n <app> --always-on true \
--generic-configurations '{"healthCheckPath": "/"}'
--https-only true is a call the Server deliberately leaves to the deployment: nothing in it
redirects HTTP to HTTPS, because an on-premises Server is routinely reached over plain HTTP behind the
customer's own proxy. Here both schemes reach the app, so without this a console opened at http://
gets a session cookie with no Secure flag.
Health Check stays at /, and the real probe goes to your monitoring. On one instance, Health
Check is not a load-balancing feature but a restart trigger: with nowhere to reroute traffic, App
Service replaces an instance after an hour of failing pings. / answers 200 with the database gone,
so it fails only when the container is dead, which is the only failure a restart can fix. /healthz
can fail on its own (Azure SQL down), and wiring it to Health Check would buy a restart every hour
that fixes nothing and strands vault-bound sessions each time. Point an availability test at
/healthz instead; it is anonymous and unaudited, so probing it every thirty seconds costs nothing.
--health-check-path is not a flag of az webapp config set; the path is a site-config property,
which is what --generic-configurations is for. From PowerShell or cmd, put the JSON in a file and
pass "@params.json".
The Key Ring Protector
The commands to mint one are on the installation page and are the same here. Two places to put it, and the second is better:
- An app setting, as shown above.
- A Key Vault reference: store the base64 as a secret, give the app's managed identity
geton it, and setKeyRingProtector__Certificate=@Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<name>/).
Keep the .pfx itself somewhere neither this app nor this subscription can reach, permanently. A
Key Vault reference is a convenient home for the running Server and is not a backup: a Key Vault the
same operator can delete is not a copy kept somewhere else.
The PKI SDK licence
Certificates__Validation__License is the Lacuna PKI SDK licence: the Server hands its own to every
Admitted machine, and nothing reads a certificate without it, so a Server without one refuses to
start, vault or not. The image carries none. From PowerShell, the value is:
[Convert]::ToBase64String([IO.File]::ReadAllBytes("LacunaPkiLicense.config"))
It is the same kind of artifact as the protector, dated and bought by somebody, so a Key Vault
reference suits it too. It expires, and a lapsed one is not a failed start: the Server logs one
Critical line and every admin page carries a banner saying nothing in the fleet or the vault can sign
until it is renewed. Renewing it is a new value (or a new secret version) and a restart.
Vault Certificates here
az webapp config appsettings set -g $rg -n $app --settings Vault__Enabled=true
The tier needs no licence beyond the Certificates__Validation__License already set, but it is one
more thing that licence is load-bearing for: with it lapsed, every Vault Certificate reports
UNTRUSTED and an import is refused, as well as the fleet being unable to sign.
One caveat is specific to this posture. App Service restarts containers for its own maintenance,
and a session bound to a Vault Certificate does not survive a restart: it ends as Stranded and the
client creates a new one. Nothing is lost permanently and workstation-bound sessions are unaffected,
but a restart cadence that is not yours is a visible one for vault signing.
REST PKI Core here
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 as a Key Vault
reference or a secret app setting like the Signer's.
az webapp config appsettings set -g $rg -n $app --settings `
RestPkiCore__Endpoint="https://core.pki.rest/" `
RestPkiCore__ApiKey="<a Worker key's application|secret>" `
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 the app must reach REST PKI Core's site address as well as the endpoint
(an app behind a VNet egress rule needs both let out), because that is where a signed document is
downloaded from. A REST PKI Core key carries a | as a Signer key does, so from PowerShell or cmd the
settings travel in a JSON file, as in step 6.
7. The schema
Nothing to do if you granted db_ddladmin in step 5. The database itself must already exist, and it
does: step 2 created it. Unlike the Docker posture, the Server cannot create one here.
To apply migrations yourself instead, set Database__MigrateOnStart=false and run from a machine with
the .NET SDK and a checkout at the deployed version:
dotnet tool restore
dotnet ef database update --project src/TrustBridge.Server \
--connection "Server=tcp:<sqlserver>.database.windows.net,1433;Database=TrustBridge;Authentication=Active Directory Default;Encrypt=True;Command Timeout=3600"
Repeat before every upgrade. Command Timeout=3600 is there on purpose.
8. Check it
az webapp log tail -g <rg> -n <app>
Look for the KeyRingPosture line naming the protector thumbprint, the vault posture line, a line
beginning Forwarded headers: saying they are honoured from whoever connects, and
Application started. A cold start takes about a minute before /healthz answers. Then:
curl -s https://<app>.azurewebsites.net/healthz # Healthy
Then sign in at https://<app>.azurewebsites.net/admin, set a real password, enroll the second
factor, and remove the two Admin__Bootstrap__* settings. The bootstrap account signs in with a
password, so leave Admin__CertificateSignIn__Required off for the first start, as on the
Docker posture.
Operating it
- Custom domain and certificate. Workstations connect to whatever hostname you give them, and the
chain must be one they trust. An App Service managed certificate on a custom domain is the simple
answer;
*.azurewebsites.networks too and ties the fleet's configuration to an Azure hostname. With Certificate Sign-In on, the Web PKI licence (Admin__CertificateSignIn__WebPkiLicense) must name the hostname people type for the console. - A Workstation Release uploads in one request. An upload on the Workstation Releases screen is up to 256 MB and has to finish within the App Service front end's request timeout, about four minutes. A package of about 100 MB does over any ordinary link.
- Upgrades. Set a new immutable tag on the app and restart it; the Server applies its migrations
as it starts. Most Server releases since 1.9.0 carry migrations (1.14.1, 1.16.0, 1.19.0, 1.22.0 and
1.24.0 do not), and an older image refuses a database a newer one has migrated, so rolling back is
restoring the database to its pre-upgrade state and deploying the previous tag, never swapping the
tag alone; going back from 1.24.0 to 1.23.0, a release with no migration, is the exception. Go
straight to 1.24.0 rather than 1.23.0 when the Audit Log is large: 1.23.0 adds an Audit Log index
with an offline build that blocks Audit Log writes while it runs, and 1.24.0 builds the same index
online at low priority on Azure SQL Database, so the Server keeps writing the Audit Log meanwhile.
Coming from Server 1.6.x, set
Certificates__Validation__Licensebefore the new tag: since 1.7.0 a Server without it refuses to start. Upgrading to 1.15.0 or later also moves the fleet to workstation protocol 6 and refuses Workstation 1.4.x. Upgrading to 1.17.0 or later stops readingSessions__SignatureAllowance, and 1.18.0 raises the floor ofSigning__StrandedAfter. See Upgrades on the installation page for all of these. - Test Signatures need nothing extra here. App Service listens on plain HTTP behind the platform's
TLS, so the loopback default is right. Keep
TestSignature:MaxTimeout(2 min by default) under the front end's 230-second request timeout. A Workstation Release delete on a small database (Basic, 5 DTU) can take a few minutes. - Restarts cost more here. Leave
DOCKER_ENABLE_CIoff and deploy deliberately. Keep Health Check on a path that cannot fail on its own. - Never scale out.