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 five services:
| Service | Purpose | Ports (host:container) |
|---|---|---|
rabbitmq |
Message broker | 5672, 15672 (management UI) |
dbinit |
Run-to-completion job: EF Core migrations + optional fresh-DB seed | none |
worker |
Singleton background services + Hangfire server | none |
web-api |
REST, Razor pages, gRPC, OpenIddict | 8080 (gRPC), 8081 (HTTP) |
notification |
gRPC streaming endpoints for client connections | 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
to be healthy.
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:
# Pin a version (defaults to "latest" if unset)
export PRIMA_VERSION=3.2.1
docker compose -f docker/docker-compose.prod.yml up -d
It needs the same docker/.env runtime variables (DB_CONNECTION_STRING,
RABBITMQ_PASSWORD, and any dbinit seed vars).
NUGET_PAT is not required — nothing is built.
| Variable | Description |
|---|---|
DB_CONNECTION_STRING |
SQL Server connection string used by all Prima services |
RABBITMQ_PASSWORD |
Password for the prima RabbitMQ broker user |
DBINIT_COMMAND |
DbInit verb. Defaults to update (migrations only) |
FortelineaWebConfig__ClientId (optional) |
Prima website login email — fresh-DB seed |
FortelineaWebConfig__ClientSecret (optional) |
Prima website login password — fresh-DB seed |
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 five optional seed variables are used together. When all are set and the database is fresh,
dbinit runs update --seed and configures the lab site.
When unset, dbinit runs migrations only.
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 in use, edit the ports: mappings in
docker/docker-compose.yml.
Confirm dbinit exited successfully and rabbitmq is healthy — the app services
block on both:
docker compose -f docker/docker-compose.prod.yml ps
docker compose -f docker/docker-compose.prod.yml logs dbinit
Deploy Prima onto an OpenShift cluster (or any Kubernetes ≥ 1.25) using the provided manifests. Download the YAML files below, then work through the sections in order.
Download YAML FilesAll 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)
+-------------+
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).| 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. |
| 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.
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.
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.
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 60-web-api.yaml
oc apply -f 70-worker.yaml
oc apply -f 80-notification.yaml
The web-api / worker / notification pods include an initContainer that polls RabbitMQ before the
main container starts, so order between those three doesn't matter — they'll wait for each other naturally.
Check overall status:
oc get pods,svc,route
All three Routes share one host: value. Search-and-replace the placeholder before applying —
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
Get all three Routes once applied:
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- |
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. |
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, or Okta. 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.
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 four image references with a pinned tag before applying. 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 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 can be switched to
RollingUpdate and scaled horizontally if you want zero-downtime upgrades — see the replica-count
note in the customization checklist.
| 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-, 60-, 70-, 80- |
2 / 4 / 2 / 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. |
| Replica count | 60-, 70-, 80- |
1 each | Web-api can scale horizontally (images are in S3 — switch its strategy to RollingUpdate to scale out). Worker scales when behind on queue depth. Notification is stateful — keep at 1 unless you understand the gRPC stream semantics. |
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.