Prima can be installed on-site or in the cloud, but is installed and managed by each client (licensed server). This allows each customer to own and protect their data, meet their own security standards and help their own users as they see fit.
Prima consists of:
As a general stance on supported versions of software, Fortelinea stays very much on top of using all new performance and security measures in new versions of software. This means newer versions are always better, as far as we are concerned.
Hardware requirements can vary quite a bit from lab to lab based on the usage of the system. We like to set both minimum and recommended requirements. We find that, if as much as possible is done using virtual machines, systems can later be tweaked based on how the lab ends up using the system.
Note that recommended requirements are based on a lab with about 12 stations/users.
| Parameter | Minimum | Recommended |
|---|---|---|
| CPU | 3.0GHz, 2 cores | 3.4+GHz, 8 cores |
| Memory | 8GB | 32GB |
| Storage | 32GB | 64GB |
| Operating System | Windows Server 2019* | Windows Server 2022 (or newer) |
* Windows Server 2019 does not fully support grpc services and will incur a significant performance hit
While we list specs for a database server, feel free to host the database on a shared server, given the overhead to support Prima is available. If you choose to allocate an SQL server VM to Prima, I would recommend using one for both the test and production databases (two databases on a single server). This will save on costs and be one less thing to manage.
Expect database growth in the neighborhood of 1GB per year
| Parameter | Minimum | Recommended |
|---|---|---|
| CPU | 3.0GHz, 2 cores | 3.4+GHz, 4 cores |
| Memory | 16GB | 64GB |
| Storage | 8GB (prefer autogrow) | 16GB (prefer autogrow) |
| Operating System | SQL Server 2019 | SQL Server 2022 |
Storage is highly configurable, but is separated here for ease of planning and use.
| Parameter | Minimum | Recommended |
|---|---|---|
| Digital Documents | 4GB | 80GB |
| Digital Images | 16GB | 1TB |
Using a human-readable vanity name makes remembering the server address easier when installing clients, accessing your internal Prima website, etc.
Prima uses an SSL certificate to encrypt traffic sent to the clients, just like an https website. While many companies have a system that places certificates on all computers, some may need to generate a certificate manually. There are only a few requirements that must be met:
Prima will communicate over your network to clients, hardware and other computer systems. It will also communicate with our web server. By ensuring all paths are clear, we can save a lot of troubleshooting time.
Also:
Before we run the Prima installer, you'll want to make sure you have the following:
It is highly recommended to set up a database backup plan. If a backup/restore is something that you can allow Fortelinea or the prima database admin user to do, it can save time and effort later.
The following is meant to be a brief guideline, but the detailed sections should be followed for specifics:
Pull prebuilt images from prima-registry.fortelinea.com and run the full Prima stack
with Docker Compose — no build toolchain required on the production host.
The stack runs six services:
| Service | Purpose | Scaling | Ports (host:container) |
|---|---|---|---|
rabbitmq |
Message broker | Single instance only — the bundled broker is not clustered | 5672, 15672 (management UI) |
dbinit |
Run-to-completion job: EF Core migrations + optional fresh-DB seed | Runs once per deploy, then exits — not a persistent service | none |
sync |
Lock coordinator: the other services take their one-at-a-time locks here (case numbers, instrument loads, containers, protocols) | Single instance only — locks live in its memory, so two instances would hand out the same lock | none (internal only) |
worker |
Singleton background services + Hangfire server | Single instance only — do not run more than one at a time | none |
web-api |
REST, Razor pages, gRPC, OpenIddict | Stateless — can run multiple instances behind a load balancer | 8080 (gRPC), 8081 (HTTP) |
notification |
gRPC streaming endpoints for client connections | Can run multiple instances | 8082 → 8080 (gRPC), 8083 → 8081 (HTTP/1 health) |
dbinit runs first; worker, web-api, and notification
wait for it to exit successfully (service_completed_successfully) and for rabbitmq
and sync to be healthy. notification also waits for web-api to be healthy.
Log in with the API client id and secret provided by Fortelinea (created with the
registry:pull scope) as username and password:
docker login prima-registry.fortelinea.com
If you ever need to pull an image directly (e.g. for testing, or to reference in your own
deployment tooling), the registry namespace fortelinea-prima is
required — it is not a placeholder or something you choose:
docker pull prima-registry.fortelinea.com/fortelinea-prima/prima-webservice:<version>
Omitting the fortelinea-prima segment produces an "authentication required" error
even with valid login credentials, since it resolves to a repository that doesn't exist.
docker/docker-compose.prod.yml is a standalone file that pulls prebuilt images from
prima-registry.fortelinea.com instead of building from source. Use it alone — do not layer it
on the base file:
docker folder and save the compose file below into it as
docker-compose.prod.yml.
.env file with the variables in
Environment Variables. Docker Compose reads .env from the compose
file's folder.
docker/token-certs — see
Token certificates. web-api will not start without them.
docker, start the stack:
# Pin a version (defaults to "latest" if unset)
export PRIMA_VERSION=3.2.1
docker compose -f docker/docker-compose.prod.yml up -d
NUGET_PAT is not required — nothing is built.
docker-compose.prod.yml# Prima standalone production compose file.
# Used ALONE (not layered on docker-compose.yml):
# docker compose -f docker/docker-compose.prod.yml up
# Pulls prebuilt images from the Prima registry instead of building from source.
# Requires docker/.env (copy from docker/.env.example); set PRIMA_VERSION to pin a tag.
#
# ⚠️ TLS TERMINATION IS REQUIRED IN FRONT OF THIS STACK ⚠️
# All service ports below bind to 127.0.0.1 ONLY. An external TLS terminator
# (cloud load balancer, customer-managed nginx/Caddy/HAProxy, etc.) MUST
# proxy public traffic to these loopback ports — never publish them on a
# public interface in cleartext. /connect/token sign-ins, bearer tokens and
# gRPC frames would otherwise traverse the network unencrypted.
# Shared logging config — JSON file driver with rotation so a misbehaving
# service can't fill the disk. Override per-service if a particular service
# needs more retention.
x-logging: &default-logging
driver: json-file
options:
max-size: "50m"
max-file: "5"
# Pin the project name so this stack can run alongside sibling projects that
# also live in a folder called `docker/` (e.g. provantisconnector) without
# service-name collisions.
name: prima
services:
rabbitmq:
image: rabbitmq:3-management
# Bind to loopback only. Cross-host federation should run via the
# external TLS terminator, not direct exposure.
ports:
- "127.0.0.1:5672:5672"
- "127.0.0.1:15672:15672"
environment:
RABBITMQ_DEFAULT_USER: prima
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
- rabbitmq_data:/var/lib/rabbitmq
restart: unless-stopped
logging: *default-logging
deploy:
resources:
limits:
memory: 2g
dbinit:
image: prima-registry.fortelinea.com/fortelinea-prima/prima-dbinit:${PRIMA_VERSION:-latest}
environment:
ConnectionStrings__PrimaContext: ${DB_CONNECTION_STRING}
FortelineaWebConfig__ClientId: ${FortelineaWebConfig__ClientId:-}
FortelineaWebConfig__ClientSecret: ${FortelineaWebConfig__ClientSecret:-}
FortelineaWebConfig__BaseUrl: ${FortelineaWebConfig__BaseUrl:-https://prima.fortelinea.com}
DBINIT_LAB_SITE_GUID: ${DBINIT_LAB_SITE_GUID:-}
DBINIT_ADMIN_EMAIL: ${DBINIT_ADMIN_EMAIL:-}
DBINIT_ADMIN_USERNAME: ${DBINIT_ADMIN_USERNAME:-}
command: ${DBINIT_COMMAND:-update}
restart: "no"
logging: *default-logging
# Lock coordinator. MUST run as exactly one instance; internal only (no ports).
sync:
image: prima-registry.fortelinea.com/fortelinea-prima/prima-sync:${PRIMA_VERSION:-latest}
environment:
ASPNETCORE_ENVIRONMENT: Docker
# Signs the internal lock-service tokens. MUST be identical on sync,
# worker, web-api and notification. Required: without it the images use
# the well-known development secret.
JwtTokenSettings__Secret: ${JWT_SECRET:?Set JWT_SECRET in docker/.env}
deploy:
replicas: 1
restart_policy:
condition: on-failure
resources:
limits:
memory: 256m
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8081/health/live"]
interval: 30s
timeout: 10s
start_period: 20s
retries: 3
logging: *default-logging
worker:
image: prima-registry.fortelinea.com/fortelinea-prima/prima-worker:${PRIMA_VERSION:-latest}
depends_on:
sync:
condition: service_healthy
rabbitmq:
condition: service_healthy
dbinit:
condition: service_completed_successfully
environment:
ASPNETCORE_ENVIRONMENT: Docker
ConnectionStrings__PrimaProduction: ${DB_CONNECTION_STRING}
RabbitMq__Username: prima
RabbitMq__Password: ${RABBITMQ_PASSWORD}
# Signs the internal lock-service tokens. MUST be identical on sync,
# worker, web-api and notification. Required: without it the images use
# the well-known development secret.
JwtTokenSettings__Secret: ${JWT_SECRET:?Set JWT_SECRET in docker/.env}
# Fortelinea cloud credentials and address, needed by every Prima component
FortelineaWebConfig__ClientId: ${FortelineaWebConfig__ClientId:-}
FortelineaWebConfig__ClientSecret: ${FortelineaWebConfig__ClientSecret:-}
FortelineaWebConfig__BaseUrl: ${FortelineaWebConfig__BaseUrl:-https://prima.fortelinea.com}
volumes:
- slide_images:/app/data/slide-images
- specimen_images:/app/data/specimen-images
- scanned_docs:/app/data/scanned-documents
deploy:
replicas: 1
restart_policy:
condition: on-failure
resources:
limits:
memory: 2g
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 3
logging: *default-logging
web-api:
image: prima-registry.fortelinea.com/fortelinea-prima/prima-webservice:${PRIMA_VERSION:-latest}
depends_on:
sync:
condition: service_healthy
rabbitmq:
condition: service_healthy
dbinit:
condition: service_completed_successfully
# ⚠️ Loopback-bound — REQUIRES an external TLS terminator. Never change
# to 0.0.0.0 without putting HTTPS in front; /connect/token traffic and
# JWTs would traverse the network in cleartext.
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:8081:8081"
environment:
ASPNETCORE_ENVIRONMENT: Docker
ConnectionStrings__PrimaProduction: ${DB_CONNECTION_STRING}
RabbitMq__Username: prima
RabbitMq__Password: ${RABBITMQ_PASSWORD}
# Signs the internal lock-service tokens. MUST be identical on sync,
# worker, web-api and notification. Required: without it the images use
# the well-known development secret.
JwtTokenSettings__Secret: ${JWT_SECRET:?Set JWT_SECRET in docker/.env}
# Fortelinea cloud credentials and address, needed by every Prima component
FortelineaWebConfig__ClientId: ${FortelineaWebConfig__ClientId:-}
FortelineaWebConfig__ClientSecret: ${FortelineaWebConfig__ClientSecret:-}
FortelineaWebConfig__BaseUrl: ${FortelineaWebConfig__BaseUrl:-https://prima.fortelinea.com}
# Public HTTPS address of the TLS terminator in front of web-api
TokenServer__Issuer: ${TOKEN_SERVER_ISSUER}
TokenServer__SigningCertificatePassword: ${TOKEN_CERTIFICATE_PASSWORD}
TokenServer__EncryptionCertificatePassword: ${TOKEN_CERTIFICATE_PASSWORD}
volumes:
# signing.pfx + encryption.pfx — see "Token certificates" in README.md
- ${TOKEN_CERTIFICATE_DIRECTORY:-./token-certs}:/app/token-certs:ro
- slide_images:/app/data/slide-images
- specimen_images:/app/data/specimen-images
- cassette_images:/app/data/cassette-images
- scanned_docs:/app/data/scanned-documents
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8081/health/live"]
interval: 30s
timeout: 10s
start_period: 60s
retries: 3
restart: unless-stopped
logging: *default-logging
deploy:
resources:
limits:
memory: 4g
notification:
image: prima-registry.fortelinea.com/fortelinea-prima/prima-notification:${PRIMA_VERSION:-latest}
depends_on:
sync:
condition: service_healthy
rabbitmq:
condition: service_healthy
web-api:
condition: service_healthy
dbinit:
condition: service_completed_successfully
# ⚠️ Loopback-bound — REQUIRES an external TLS terminator (see web-api).
ports:
# Host 8082 → container 8080 (gRPC/HTTP2 streaming endpoints).
# Host 8083 → container 8081 (HTTP/1 health endpoint).
- "127.0.0.1:8082:8080"
- "127.0.0.1:8083:8081"
environment:
ASPNETCORE_ENVIRONMENT: Docker
ConnectionStrings__PrimaProduction: ${DB_CONNECTION_STRING}
RabbitMq__Username: prima
RabbitMq__Password: ${RABBITMQ_PASSWORD}
# Signs the internal lock-service tokens. MUST be identical on sync,
# worker, web-api and notification. Required: without it the images use
# the well-known development secret.
JwtTokenSettings__Secret: ${JWT_SECRET:?Set JWT_SECRET in docker/.env}
# Fortelinea cloud credentials and address, needed by every Prima component
FortelineaWebConfig__ClientId: ${FortelineaWebConfig__ClientId:-}
FortelineaWebConfig__ClientSecret: ${FortelineaWebConfig__ClientSecret:-}
FortelineaWebConfig__BaseUrl: ${FortelineaWebConfig__BaseUrl:-https://prima.fortelinea.com}
# Reads the token server's signing keys from web-api's discovery document
TokenServer__Authority: http://web-api:8081
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8081/health/live"]
interval: 30s
timeout: 10s
start_period: 90s
retries: 3
restart: unless-stopped
logging: *default-logging
deploy:
resources:
limits:
memory: 2g
volumes:
rabbitmq_data:
slide_images:
specimen_images:
cassette_images:
scanned_docs:
| Variable | Description |
|---|---|
DB_CONNECTION_STRING |
SQL Server connection string used by all Prima services |
RABBITMQ_PASSWORD |
Password for the prima RabbitMQ broker user |
TOKEN_CERTIFICATE_PASSWORD |
Password of the token server's signing.pfx and encryption.pfx. Required in production |
TOKEN_CERTIFICATE_DIRECTORY |
Host folder holding signing.pfx and encryption.pfx. Defaults to ./token-certs (beside the compose file) |
TOKEN_SERVER_ISSUER |
Public HTTPS address clients reach Prima on (e.g. https://prima.your-org.com) |
JWT_SECRET |
Required. Signs the internal tokens the services present to the sync lock coordinator;
passed to sync, worker, web-api, and notification.
Use a long random value (at least 128 bits). Compose refuses to start without it.
|
FortelineaWebConfig__ClientId |
Prima website login email (provided by Fortelinea). Used by every Prima service, and by the fresh-DB seed |
FortelineaWebConfig__ClientSecret |
Prima website login password (provided by Fortelinea). Used by every Prima service, and by the fresh-DB seed |
FortelineaWebConfig__BaseUrl (optional) |
Fortelinea cloud address. Defaults to https://prima.fortelinea.com |
DBINIT_COMMAND (optional) |
DbInit verb. Defaults to update (migrations only) |
DBINIT_LAB_SITE_GUID (optional) |
Lab site GUID to configure — fresh-DB seed |
DBINIT_ADMIN_EMAIL (optional) |
Initial admin email — fresh-DB seed |
DBINIT_ADMIN_USERNAME (optional) |
Initial admin username — fresh-DB seed |
The three DBINIT_* seed variables are used together with the two
FortelineaWebConfig credentials. When all five are set and the database is fresh,
dbinit runs update --seed and configures the lab site.
Otherwise dbinit runs migrations only.
web-api signs the tokens it issues (sign-ins, API access) and encrypts the ones only it reads back
(such as refresh tokens) with two certificates, read from /app/token-certs/signing.pfx and /app/token-certs/encryption.pfx.
Create them once and keep them: replacing them signs everyone out and invalidates every refresh token
integrations hold.
Put both files in TOKEN_CERTIFICATE_DIRECTORY (default token-certs beside the compose
file), protected with TOKEN_CERTIFICATE_PASSWORD. To create them, run these from the
docker folder:
export TOKEN_CERTIFICATE_PASSWORD='a long random value'
mkdir -p token-certs && cd token-certs
openssl req -x509 -newkey rsa:3072 -nodes -keyout signing.key -out signing.crt -days 3650 \
-subj "/CN=Prima Token Signing" -addext "keyUsage=critical,digitalSignature"
openssl pkcs12 -export -inkey signing.key -in signing.crt -out signing.pfx -passout env:TOKEN_CERTIFICATE_PASSWORD
openssl req -x509 -newkey rsa:3072 -nodes -keyout encryption.key -out encryption.crt -days 3650 \
-subj "/CN=Prima Token Encryption" -addext "keyUsage=critical,keyEncipherment"
openssl pkcs12 -export -inkey encryption.key -in encryption.crt -out encryption.pfx -passout env:TOKEN_CERTIFICATE_PASSWORD
rm signing.key signing.crt encryption.key encryption.crt
app user (uid 1654).web-api instance must use the same two files.web-api at
startup with a message naming the TokenServer setting to fix.
TokenServer__SigningCertificateBase64 and
TokenServer__EncryptionCertificateBase64 to the PFX files' contents (base64 -w0 signing.pfx),
set the two passwords as usual (TokenServer__SigningCertificatePassword /
TokenServer__EncryptionCertificatePassword), and set TokenServer__SigningCertificatePath
and TokenServer__EncryptionCertificatePath to an empty value so the image's default paths do not apply.
These are not HTTPS certificates. web-api runs on plain HTTP behind your TLS terminator;
notification reads the token server's public keys from web-api's discovery document
over the internal network (TokenServer__Authority: http://web-api:8081, already set in the compose file).
Every port in docker-compose.prod.yml is bound to 127.0.0.1 only, and the services speak
plain HTTP. A TLS terminator on the same host (or a load balancer that reaches it) must accept HTTPS on your public
hostname and route by path to three backends:
| Path | Backend | Protocol |
|---|---|---|
/grpc/pubsub/ |
127.0.0.1:8082 (notification) |
HTTP/2 cleartext (h2c, gRPC) |
/grpc/ |
127.0.0.1:8080 (web-api) |
HTTP/2 cleartext (h2c, gRPC) |
/ (everything else) |
127.0.0.1:8081 (web-api) |
HTTP/1.1 (REST, /connect/token, web pages, WebSocket upgrades) |
/grpc/pubsub/ before /grpc/ before /.grpc_pass, not proxy_pass). Port 8080 rejects HTTP/1.1.
Host header and set X-Forwarded-Proto (and X-Forwarded-For)
on the HTTP/1.1 route, so Prima builds https:// addresses on your hostname.
TOKEN_SERVER_ISSUER.The desktop applications use that one address for everything: "PrimaGrpcApiUrl": "https://prima.your-org.com".
Named Docker volumes hold the broker's data and stored images:
| Volume | Purpose |
|---|---|
rabbitmq_data | RabbitMQ persistence |
slide_images | Scanned slide images |
specimen_images | Specimen photographs |
cassette_images | Cassette images (web-api only) |
scanned_docs | Requisition forms and other scanned documents |
The image volumes hold patient data: include them, along with the SQL Server database and your token certificate files, in your backups. For production, consider mounting host directories or network shares in place of named volumes so they are easier to back up.
sync, worker, web-api, and notification each expose a health check
probing /health/live. Check container health with:
docker inspect --format='{{.State.Health.Status}}' <container-name>
.env file causes errors when sourcingIf you see \r: command not found, the file has Windows line endings:
sed -i 's/\r$//' docker/.env
If ports 8080/8081/8082/8083 or 5672/15672 are already in use on the host, change the host side of the
ports: mappings in docker/docker-compose.prod.yml (keep the 127.0.0.1:
prefix), and point your TLS terminator at the new ports.
Confirm dbinit exited successfully and rabbitmq and sync are healthy
— the app services block on all three (and notification also on web-api):
docker compose -f docker/docker-compose.prod.yml ps
docker compose -f docker/docker-compose.prod.yml logs dbinit
If web-api exits at startup, check its log for a TokenServer message — the
token certificate files are missing, unreadable, or have the wrong password
(see Token certificates):
docker compose -f docker/docker-compose.prod.yml logs web-api
Deploy Prima onto an OpenShift cluster using the provided manifests. Download the YAML files below, then work through the sections in order.
The manifests are plain Kubernetes (1.25 or newer) apart from the three OpenShift Route objects in
60-web-api.yaml and 80-notification.yaml. On another Kubernetes distribution, replace
those with an Ingress (or Gateway) that does the same path routing over HTTP/2 — see
Architecture.
All external traffic — REST, the Prima desktop (WPF) apps over gRPC, and notification streams over gRPC — uses one hostname. Three OpenShift Routes share that host and fan out by URL path.
Browsers, WPF apps, external REST clients
|
| HTTPS (HTTP/1 or HTTP/2 via ALPN)
v
+------------------------------------+
| OpenShift router (HAProxy) |
| Single host: prima.example.com |
| Edge-TLS termination |
+-+------------+-----------+---------+
| | |
path: | /grpc/ | /grpc/ | (catch-all)
| pubsub | |
| | |
v v v
+-------------+ +-------+ +-------+
| notification| |web-api| |web-api|
| :8080 h2c | |:8080 | |:8081 |
| Route | |h2c | |HTTP/1 |
| notification| |Route | |Route |
| -grpc | |web-api| |web-api|
+-------------+ |-grpc | |-rest |
+---+---+ +---+---+
| |
v v
+-----------------+
| Service web-api | ClusterIP
| (grpc + http) |
+--------+--------+
|
v
+-----------------+
| web-api Pod |
+--+-----------+--+
| |
| gRPC | AMQP
v v
+-----------------+ +----------+
| notification | | rabbitmq |
| (ClusterIP svc) | | |
+-----------------+ +----+-----+
^
| AMQP
|
+-------------+
| worker | (no Service)
+-------------+
web-api, worker and notification all take their locks from:
+-----------------+
| sync | lock coordinator, exactly one
| (ClusterIP svc) | replica, no Route
+-----------------+
Path matching (longest-prefix wins):
| Path | Backend | Protocol |
|---|---|---|
/grpc/pubsub/... |
notification:8080 |
h2c (gRPC) |
/grpc/... |
web-api:8080 |
h2c (gRPC) |
/... (catch-all) |
web-api:8081 |
HTTP/1 (REST, /connect/token, Razor, SignalR) |
rabbitmq-data — a single 10 Gi ReadWriteOnce (RWO) volume, used only by the bundled RabbitMQ broker.prima-registry.fortelinea.com (provided by Fortelinea).openssl, see step 5.| Requirement | Notes |
|---|---|
| OpenShift 4.11+ | oc CLI logged in. 4.11 is the minimum version where the appProtocol: h2c Service port hint is honored by the router (required for gRPC). For OCP 4.9–4.10, additional manual router config is needed — contact Fortelinea. |
| HTTP/2 enabled on the IngressController | One-time cluster-admin action so gRPC Routes work end-to-end. See Cluster prerequisite below. |
| SQL Server | TCP-reachable from the cluster. Customer-managed. Connection string goes into the Secret. |
| S3 object storage | An S3 bucket (AWS S3 or any S3-compatible store) for images, scanned documents, and label templates, plus an access key / secret key pair. Prima writes images here instead of to in-cluster volumes, so no RWX StorageClass is required. Bucket names and region are set on the Deployments; the keys go into the Secret. |
| (default) StorageClass | Only the bundled RabbitMQ broker needs a PVC, and it is RWO — satisfied by virtually any StorageClass, so the cluster default is fine. If you use an external RabbitMQ broker you need no PVCs at all. |
| Registry credentials |
Username + access token for prima-registry.fortelinea.com, provided by Fortelinea.
Image references must include the fortelinea-prima namespace, e.g.
prima-registry.fortelinea.com/fortelinea-prima/prima-webservice:<version> —
it is required, not a placeholder.
|
| TLS certificate | Optional — supply a custom cert for the Routes, or let OpenShift use its default ingress cert. WPF clients must trust the cert chain. |
oc new-project prima
The Prima WPF apps connect to the gRPC Route over HTTPS/HTTP-2. The default OpenShift IngressController does not have
HTTP/2 enabled until you set the annotation below. This is a one-time, cluster-wide action a cluster
admin must perform before applying 60-web-api.yaml:
oc -n openshift-ingress-operator annotate \
ingresscontroller/default \
ingress.operator.openshift.io/default-enable-http2=true
The default ingress router will roll automatically. Verify after rollout:
oc -n openshift-ingress get pods -l ingresscontroller.operator.openshift.io/deployment-ingresscontroller=default
If your cluster has multiple custom IngressControllers and Prima will be exposed through a non-default one, apply the same annotation to that IngressController.
Every Prima Deployment and the dbinit Job pull their images with a secret named prima-registry-pull.
Use the registry username and access token provided by Fortelinea:
oc create secret docker-registry prima-registry-pull \
--docker-server=prima-registry.fortelinea.com \
--docker-username='<your-registry-username>' \
--docker-password='<your-registry-token>'
Without it, every Prima pod stays in ImagePullBackOff.
cp 20-secrets.example.yaml 20-secrets.yaml
# Edit 20-secrets.yaml — replace every CHANGE_ME value.
oc apply -f 20-secrets.yaml
20-secrets.yaml. The .example suffix on the template is intentional; add 20-secrets.yaml to your own ignore list if you keep this directory under version control.
Notable keys:
jwt-secret — signs the internal tokens the Prima pods present to the sync lock
coordinator. Web-api, worker, notification, and sync all read this one key. Use a long random value
(≥128 bits); if it is left unset the services fall back to a well-known development value, so never
run production that way. (Sign-in tokens are signed with the token certificates from step 5, not with this.)
token-certificate-password — the password of the two token certificate files created in step 5.fortelinea-client-id / fortelinea-client-secret — the Prima website credentials
provided by Fortelinea. Every Prima component uses them, and dbinit uses them for the first-run seed.
oidc-client-secret — optional; leave blank unless you turn on single sign-on.
Web-api signs the tokens it issues, and encrypts the ones only it reads back (such as refresh tokens), with two certificates, kept in their own Secret,
prima-token-certificates, and mounted at /app/token-certs. Create them once and keep
them: replacing them signs everyone out and invalidates every refresh token integrations hold. The password is
token-certificate-password from 20-secrets.yaml.
export TOKEN_CERTIFICATE_PASSWORD='the token-certificate-password value'
openssl req -x509 -newkey rsa:3072 -nodes -keyout signing.key -out signing.crt -days 3650 \
-subj "/CN=Prima Token Signing" -addext "keyUsage=critical,digitalSignature"
openssl pkcs12 -export -inkey signing.key -in signing.crt -out signing.pfx -passout env:TOKEN_CERTIFICATE_PASSWORD
openssl req -x509 -newkey rsa:3072 -nodes -keyout encryption.key -out encryption.crt -days 3650 \
-subj "/CN=Prima Token Encryption" -addext "keyUsage=critical,keyEncipherment"
openssl pkcs12 -export -inkey encryption.key -in encryption.crt -out encryption.pfx -passout env:TOKEN_CERTIFICATE_PASSWORD
oc create secret generic prima-token-certificates --from-file=signing.pfx --from-file=encryption.pfx
rm signing.key signing.crt encryption.key encryption.crt signing.pfx encryption.pfx
These are not HTTPS certificates — the Routes still terminate TLS with the ingress certificate. A token
certificate that is missing, has the wrong password, or cannot do its job stops web-api at startup with a
message naming the TokenServer setting to fix (oc logs deploy/web-api).
Only the bundled RabbitMQ broker needs persistent storage (a single RWO PVC). Images and documents go to S3, so there are no image PVCs.
oc apply -f 30-pvcs.yaml
Verify it reaches Bound:
oc get pvc -w
If it stays Pending, check oc describe pvc rabbitmq-data — usually no default StorageClass is set on the cluster.
40-rabbitmq.yaml — Prima then needs no PVCs at all. See External RabbitMQ broker.
All three Routes share one host: value. Replace the placeholder before applying anything —
the same hostname must appear in 60-web-api.yaml (two Routes) and 80-notification.yaml (one Route):
sed -i 's/prima\.example\.com/prima.your-org.com/g' 60-web-api.yaml 80-notification.yaml
The same command also sets TokenServer__Issuer in 60-web-api.yaml — the public
address (https:// + the Route host) that the tokens web-api issues carry as their issuer.
The files use numeric prefixes so oc apply -f . would almost work, but dbinit
must complete before the long-running services start. Apply in two stages:
# Stage 1 — broker + database migrations
# (Using an external RabbitMQ broker? Skip the 40-rabbitmq.yaml line.)
oc apply -f 40-rabbitmq.yaml
oc apply -f 50-dbinit-job.yaml
oc wait --for=condition=complete --timeout=10m job/prima-dbinit
# Stage 2 — application services
oc apply -f 55-sync.yaml
oc apply -f 60-web-api.yaml
oc apply -f 70-worker.yaml
oc apply -f 80-notification.yaml
Apply 55-sync.yaml first: until the sync pod is ready, anything that needs a lock (assigning a case
number, loading an instrument) fails and can be retried.
The web-api / worker / notification pods include an initContainer that polls RabbitMQ before the
main container starts, and notification has a second one that waits for web-api, so order between those three
doesn't matter — they'll wait for each other naturally.
Check overall status:
oc get pods,svc,route
Confirm the three Routes:
oc get routes -o custom-columns=NAME:.metadata.name,HOST:.spec.host,PATH:.spec.path,SERVICE:.spec.to.name,PORT:.spec.port.targetPort
You should see three rows on the same host with paths <none>, /grpc, and /grpc/pubsub.
Prima stores slide / specimen / cassette images, scanned documents, and label templates in S3-compatible object
storage rather than in-cluster volumes. Configuration lives on the web-api and worker
Deployments; the access keys come from the Secret.
| Env var | File | Default | Notes |
|---|---|---|---|
ImageStorage__File__RootDirectory |
60-, 70- |
"" |
Empty string disables the on-disk backend, forcing the S3 backend. Leave empty. |
ImageStorage__S3__BucketName |
60-, 70- |
prima-images |
Bucket for slide/specimen/cassette images + scanned docs. |
LabelTemplateStorage__S3__BucketName |
60- |
prima-label-templates |
Bucket for label templates (web-api only). |
StorageCredentialProfiles__0__Region |
60-, 70- |
us-east-1 |
Region for the buckets. |
StorageCredentialProfiles__0__Name / ImageStorage__S3__CredentialProfile / LabelTemplateStorage__S3__CredentialProfile |
60-, 70- (LabelTemplateStorage__S3__CredentialProfile: 60- only) |
AWS |
Logical name tying the storage backends to credential profile 0. Keep them all matching. |
aws-access-key-id / aws-secret-access-key |
20-secrets.yaml |
— | Access key pair, surfaced as StorageCredentialProfiles__0__AccessKeyId / __SecretAccessKey. |
S3Settings__TimeoutSeconds / S3Settings__MaxRetryAttempts |
60-, 70- |
30 / 3 |
Client timeout and retry tuning. |
StorageCredentialProfiles__0__Endpoint with the store's URL (for example
https://minio.your-org.com) on both web-api and worker. When an endpoint is
set, Prima uses path-style addressing (https://endpoint/bucket/key) and ignores
StorageCredentialProfiles__0__Region.
By default Prima uses its built-in (local) authentication. To authenticate users against your directory instead,
set the LDAP variables on the web-api Deployment. A commented-out block is already present in
60-web-api.yaml — uncomment and fill it in:
| Env var | Example | Notes |
|---|---|---|
LdapConfiguration__AuthenticationType |
Ldap |
Prima (local, default), Ldap, Okta (users type their Okta password into Prima), or Oidc (single sign-on; see the next section). Set to Ldap to enable directory auth. |
LdapConfiguration__Host |
dc01.example.com |
Domain-controller / LDAP server hostname. |
LdapConfiguration__Port |
636 |
636 for LDAPS, 389 for plain LDAP. |
LdapConfiguration__Protocol |
LDAPS |
LDAP or LDAPS. |
LdapConfiguration__Domain |
example.com |
AD domain. |
LdapConfiguration__UserStore |
ou=Users,dc=example,dc=com |
Base DN for the user search. |
LdapConfiguration__AuthType |
Negotiate |
Negotiate, Basic, or Anonymous. |
LdapConfiguration__CertificateVerificationMethod |
Default |
Default, SkipVerification, or CustomCertificate (with LdapConfiguration__X509CertificatePath). |
These values are not secrets (the directory connection uses the integrated AuthType above rather
than a stored bind password), so they live as plain value: entries on the Deployment. This is
authentication configuration only — it controls how users sign in, not Prima's in-app
authorization/roles, which remain database-driven.
With single sign-on, everyone signs in through your organization's identity provider in the browser: the website directly, and the desktop apps by opening a browser window. Prima passwords are not used, and password sign-ins are refused. Okta, Microsoft Entra ID (formerly Azure AD), and other OpenID Connect providers are supported.
Oidc below, not the Okta value from the LDAP table. That
older mode has users type their Okta password into Prima.
Only the website is registered; the desktop apps sign in through Prima, so there is nothing to register per workstation. You need these two addresses, on the same hostname as the Routes (Set the public hostname):
| Redirect URI (required) | https://prima.your-org.com/signin-oidc |
| Sign-out redirect URI (optional, if the provider asks) | https://prima.your-org.com/signout-callback-oidc |
common / organizations addresses.
openid, profile, and email
(Microsoft Graph, delegated).
default custom authorization server. If your Okta org does not have custom
authorization servers, set OidcConfiguration__OktaAuthorizationServerId to an empty value
("") to use the org authorization server instead.
Register a confidential web application with the authorization code flow and the redirect URI above, and set
Provider to Generic with Authority set to the provider's issuer URL
(it must be https://).
Put the client secret in 20-secrets.yaml as oidc-client-secret and re-apply it. Then, in
60-web-api.yaml, uncomment the single sign-on block (below the LDAP block) and fill it in. The block covers the
common settings; add any other variable from the table below that you need (for example
OidcConfiguration__Authority for a Generic provider, or OidcConfiguration__EntraCloud). Only web-api
needs these settings.
| Env var | Example | Notes |
|---|---|---|
LdapConfiguration__AuthenticationType |
Oidc |
Turns single sign-on on. Set it once — not also in the LDAP block. |
OidcConfiguration__Provider |
Entra |
Entra, Okta, or Generic. |
OidcConfiguration__DisplayName |
Sign in with Microsoft |
The sign-in button's text. |
OidcConfiguration__EntraTenantId |
00000000-… |
Entra only: the Directory (tenant) ID, or a verified domain. |
OidcConfiguration__EntraCloud |
Public |
Entra only: Public (default), USGovernment, or China. |
OidcConfiguration__OktaDomain |
yourcompany.okta.com |
Okta only. |
OidcConfiguration__OktaAuthorizationServerId |
default |
Okta only: default (default), another custom authorization server's id, or "" for the org authorization server. |
OidcConfiguration__Authority |
https://login.example.com |
Generic only: the issuer URL. |
OidcConfiguration__ClientId |
From step 1. | |
OidcConfiguration__ClientSecret |
From the oidc-client-secret Secret key (already wired in the commented block). |
|
OidcConfiguration__UserMatchClaims |
preferred_username email |
Default shown. See step 3. |
OidcConfiguration__UserMatchIgnoreDomain |
false |
true when Prima user names have no @domain. See step 3. |
Then oc apply -f 60-web-api.yaml. At startup web-api logs Sign-in mode: Oidc. If a setting
is missing it logs LdapConfiguration:AuthenticationType is Oidc, but sign-in uses Prima because OidcConfiguration is incomplete:
followed by what to fix, and keeps password sign-in:
oc logs deploy/web-api | grep -i "sign-in"
Single sign-on does not create Prima accounts: each person still needs a Prima user, as today. The first time someone
signs in, Prima compares the provider's claims, in the order of UserMatchClaims, with Prima user names
(ignoring case). The first claim that matches decides, and it must match exactly one active user. From then on the two
are linked by the provider's permanent id for that person, so a later change of name or e-mail at the provider changes
nothing. An administrator can remove the link in the Control Panel.
preferred_username is the user principal name, e.g. jsmith@contoso.com.preferred_username is the Okta username, usually the e-mail address.jsmith (common for sites that used LDAP), set
UserMatchIgnoreDomain to true: jsmith@contoso.com then also matches
jsmith.
dbinit-admin-username in
20-secrets.yaml must be the administrator's sign-in name at the provider (e.g.
jsmith@contoso.com), or the part before the @ with UserMatchIgnoreDomain set to
true. Otherwise nobody can sign in to set up everyone else.
https://prima.your-org.com/. The sign-in page shows a single button with your
DisplayName, and no user name and password boxes.
https://<the Route host>/signin-oidc.
Prima builds it from the address the browser used, so users must reach Prima on the Route hostname, over HTTPS.
PrimaGrpcApiUrl.
oc logs deploy/web-api | grep "Single sign-on refused"). Create the user with that user name, or
adjust UserMatchClaims / UserMatchIgnoreDomain.
Entra client secrets expire (at most two years after they are created). Before that date, create a new secret at the
provider, put it in oidc-client-secret, re-apply 20-secrets.yaml, and restart web-api:
oc rollout restart deployment/web-api
Set LdapConfiguration__AuthenticationType back to Prima (or comment the block out) and
re-apply 60-web-api.yaml. Password sign-in returns; users who only ever signed in through the provider
have no Prima password and need one set.
The bundled 40-rabbitmq.yaml runs RabbitMQ in-cluster and is the simplest option. If you'd rather
supply your own broker — a dedicated server, a managed service such as AWS Amazon MQ for RabbitMQ,
or a container you operate — point Prima at it instead:
40-rabbitmq.yaml, and skip the rabbitmq-data PVC in
30-pvcs.yaml (Prima then needs no PVCs at all).
- name: RabbitMq__Host
value: my-broker.example.com # or the Amazon MQ endpoint hostname
- name: RabbitMq__Port
value: "5672" # 5671 for AMQPS/TLS
- name: RabbitMq__VirtualHost
value: "/"
- name: RabbitMq__Username
value: prima
# RabbitMq__Password already comes from the prima-secrets "rabbitmq-password" key
wait-for-rabbitmq initContainers poll rabbitmq:5672 by name. Update them to
your broker's host:port (or remove them if the broker is always reachable) so the pods don't block on startup:
- until nc -z my-broker.example.com 5672; do echo "waiting for rabbitmq"; sleep 2; done
Set RabbitMq__Host consistently on all three client Deployments. The credentials must be valid on
your broker; with Amazon MQ, create the user and vhost in the broker console first.
Replace :latest in the five image references with a pinned tag (for example
prima-registry.fortelinea.com/fortelinea-prima/prima-webservice:3.2.1) before applying. Keep the
fortelinea-prima/ namespace. Recommended upgrade order:
# 1. Run migrations for the new version
oc delete job prima-dbinit
oc apply -f 50-dbinit-job.yaml # ensure image tag is updated
oc wait --for=condition=complete --timeout=10m job/prima-dbinit
# 2. Roll the services
oc apply -f 55-sync.yaml
oc apply -f 60-web-api.yaml
oc apply -f 70-worker.yaml
oc apply -f 80-notification.yaml
The Deployments use a Recreate rollout strategy (single replica each). Expect ~30s of API downtime per
service. Now that images live in S3 rather than on an RWO volume, web-api and notification
can be switched to RollingUpdate and scaled horizontally if you want zero-downtime upgrades —
worker and sync must stay at a single replica — see the replica-count note in the customization checklist.
Actions that must happen one at a time — assigning the next case number, loading an instrument, changing a
container or protocol — are coordinated by the sync pod, so they stay one-at-a-time across every
web-api replica and the worker. The other pods reach it at http://sync:8080 (already set in their
images as LockService__Provider=Remote / LockService__RemoteAddress) and sign their
requests with the shared jwt-secret.
Recreate strategy keeps an upgrade from briefly running two.| Setting | File | Default | Tune for |
|---|---|---|---|
| S3 buckets / region | 60-, 70- |
prima-images, prima-label-templates, us-east-1 |
Your object-storage layout. See Configuring object storage. |
rabbitmq-data PVC size |
30-pvcs.yaml |
10 Gi | Broker message backlog. Only relevant to the bundled broker. |
| Memory limits | 40-, 55-, 60-, 70-, 80- |
2 Gi / 256 Mi / 4 Gi / 2 Gi / 2 Gi | Worker memory scales with concurrent slide-image processing. |
| Shared Route hostname | 60-web-api.yaml ×2, 80-notification.yaml ×1 |
prima.example.com |
Single DNS hostname for all client traffic. All three Routes MUST match. |
| TLS certificate | All three Routes | OpenShift default ingress cert | Add tls.key / tls.certificate / tls.caCertificate (same cert on all three Routes) if you bring your own. |
| LDAP / AD auth | 60-web-api.yaml |
local (Prima) auth | Uncomment the LdapConfiguration__* block to authenticate against your directory. See LDAP / Active Directory. |
| Single sign-on (Okta / Entra) | 60-web-api.yaml, 20-secrets.yaml |
local (Prima) auth | Uncomment the single sign-on block and set oidc-client-secret. See Single sign-on. |
| Replica count | 60-, 70-, 80- |
1 each | Web-api and notification can scale horizontally (images are in S3 — switch their strategy to RollingUpdate to scale out; notification is otherwise stateful per client connection, so make sure clients reconnect cleanly across instances). Worker must stay at 1 — it runs singleton background services and the Hangfire server, and has no Service in front of it, so running more than one instance is not supported. Sync (55-) must stay at 1 — see Sync. |
The Workstation, Pathologist, LabManager, ControlPanel, and LabelTemplateCreator clients all connect to the gRPC API. They use one URL for both web-api gRPC and notification gRPC streams — the path-based fan-out at the Route layer takes care of the rest:
"PrimaGrpcApiUrl": "https://prima.your-org.com"
REST clients (browser, scripts, third-party integrations) use the same hostname — /api/v1/...,
/api/v2/..., /connect/token all route to the REST backend via the catch-all.
The WPF client uses Grpc.Net.Client with a standard HTTPS channel — no
Http2UnencryptedSupport switch and no extra TLS configuration is needed, because the wire is HTTP/2
over TLS (the router terminates TLS and forwards h2c to the pod internally). Clients must trust the certificate
chain presented by the Routes; if you bring your own cert, distribute the CA to client machines through your
normal workstation provisioning.
ImagePullBackOffoc get secret prima-registry-pull, see
step 3) and that the credentials work with
docker login prima-registry.fortelinea.com. Image references must include the
fortelinea-prima/ namespace; without it the pull fails with "authentication required" even
with valid credentials.
Init:0/1oc logs <pod> -c wait-for-rabbitmq; if it keeps printing "waiting", look at the RabbitMQ
pod for crashes.
oc logs job/prima-dbinit. The most common causes are a typo in the connection string in
the Secret, or SQL Server not reachable from the cluster. Fix the Secret, then
oc delete job prima-dbinit && oc apply -f 50-dbinit-job.yaml.
oc logs deploy/web-api for a TokenServer message: the
prima-token-certificates Secret is missing, or token-certificate-password does not
match the files (see step 5).
rabbitmq-data PVC stays PendingstorageClassName: <name>
under spec in 30-pvcs.yaml.
AccessDenied, NoSuchBucket, timeouts)aws-access-key-id /
aws-secret-access-key Secret values. Confirm the buckets exist in the configured region and the key
pair can read and write them. For S3-compatible stores, check StorageCredentialProfiles__0__Endpoint
(see Configuring object storage).
Grpc.Core.RpcException: Status(StatusCode=Internal, ...) or HTTP/2 negotiation
failures mean the IngressController doesn't have HTTP/2 enabled. Run the oc annotate command from
step 2 and wait for the router pods to roll. Verify with curl --http2 -I https://<host>/grpc/;
the response should show HTTP/2, not HTTP/1.1.
host:. Run oc get routes and confirm all three
show the same hostname; the router matches the host first, then the path.
503 Application is not available/grpc, so the router sends it to the gRPC Route. Contact Fortelinea.